diff options
author | Lin Jen-Shin <godfat@godfat.org> | 2017-04-21 15:51:33 +0800 |
---|---|---|
committer | Lin Jen-Shin <godfat@godfat.org> | 2017-04-21 15:51:33 +0800 |
commit | b43cabaf497988c0efee7106770c17be10d3da1b (patch) | |
tree | 7c30cfd403f4bf8a74011d192516c0c9c20d4469 | |
parent | 8a570944a9d0f0e4cd607e5d2dfe2eac4bd83f47 (diff) | |
parent | 4b37961539ea08d4a28f78de763e719f50ba6470 (diff) | |
download | gitlab-ce-b43cabaf497988c0efee7106770c17be10d3da1b.tar.gz |
Merge remote-tracking branch 'upstream/master' into sh-fix-issue-31215
* upstream/master: (109 commits)
Update CI templates to include 9.1 templates
Change spec folder to match the assets one
Update style_guide_js.md
Milestones documentation refactor
Adds documentation entry: Don't user forEach, aim for code without side effects
Move kube namespace section to the variables one
Changed milestone.to_reference calls into milestone.title for the show, edit and top views
Update move icon to match others
Issue Title Show Focus Check On Load
Update Kubernetes namespace documentation
Store projects in metrics for email replies
Refactor into .vue files
Adds vue js example application and documentation
Add ES lint support to identify poorly written Promises
Update plantuml.md to add the actual link.
Fixed wording
Add metrics events for incoming emails
Remove helpers assigned_issuables_count and cached_assigned_issuables_count
Refactor into .vue files part 2
Fix headings
...
276 files changed, 4021 insertions, 1156 deletions
diff --git a/.eslintrc b/.eslintrc index 57a08a06527..aba8112c5a9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,7 +14,8 @@ "plugins": [ "filenames", "import", - "html" + "html", + "promise" ], "settings": { "html/html-extensions": [".html", ".html.raw", ".vue"], @@ -26,6 +27,7 @@ }, "rules": { "filenames/match-regex": [2, "^[a-z0-9_]+$"], - "no-multiple-empty-lines": ["error", { "max": 1 }] + "no-multiple-empty-lines": ["error", { "max": 1 }], + "promise/catch-or-return": "error" } } diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d84725b202a..f188ee29223 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -201,7 +201,13 @@ rake config_lint: *exec rake brakeman: *exec rake flay: *exec license_finder: *exec -rake downtime_check: *exec +rake downtime_check: + <<: *exec + except: + - master + - tags + - /^[\d-]+-stable(-ee)?$/ + rake ee_compat_check: <<: *exec only: @@ -278,7 +284,6 @@ rake karma: cache: paths: - vendor/ruby - - node_modules stage: test <<: *use-db <<: *dedicated-runner @@ -377,9 +382,6 @@ coverage: lint:javascript: <<: *dedicated-runner - cache: - paths: - - node_modules/ stage: test before_script: [] script: @@ -387,9 +389,6 @@ lint:javascript: lint:javascript:report: <<: *dedicated-runner - cache: - paths: - - node_modules/ stage: post-test before_script: [] script: diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index f93208944a1..adb45b0606d 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -239,6 +239,9 @@ AwardsHandler if (menu) { menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); } + }).catch((err) => { + emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>'); + throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); }); }; diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index c9fe23aec75..4568b86f298 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -35,7 +35,7 @@ export default class BlobFileDropzone { this.removeFile(file); }); this.on('sending', function (file, xhr, formData) { - formData.append('target_branch', form.find('input[name="target_branch"]').val()); + formData.append('branch_name', form.find('input[name="branch_name"]').val()); formData.append('create_merge_request', form.find('.js-create-merge-request').val()); formData.append('commit_message', form.find('.js-commit-message').val()); }); diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index b749ef43cd3..b6dee8177d2 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -1,5 +1,6 @@ /* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ /* global BoardService */ +/* global Flash */ import Vue from 'vue'; import VueResource from 'vue-resource'; @@ -93,7 +94,7 @@ $(() => { Store.addBlankState(); this.loading = false; - }); + }).catch(() => new Flash('An error occurred. Please try again.')); }, methods: { updateTokens() { diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index adbd82cb687..b13386536bf 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -57,12 +57,15 @@ export default { }, loadNextPage() { const getIssues = this.list.nextPage(); + const loadingDone = () => { + this.list.loadingMore = false; + }; if (getIssues) { this.list.loadingMore = true; - getIssues.then(() => { - this.list.loadingMore = false; - }); + getIssues + .then(loadingDone) + .catch(loadingDone); } }, toggleForm() { diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index fb0aac3c0e4..fdab317dc23 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -51,11 +51,13 @@ gl.issueBoards.IssuesModal = Vue.extend({ showAddIssuesModal() { if (this.showAddIssuesModal && !this.issues.length) { this.loading = true; + const loadingDone = () => { + this.loading = false; + }; this.loadIssues() - .then(() => { - this.loading = false; - }); + .then(loadingDone) + .catch(loadingDone); } else if (!this.showAddIssuesModal) { this.issues = []; this.selectedIssues = []; @@ -67,11 +69,13 @@ gl.issueBoards.IssuesModal = Vue.extend({ if (this.$el.tagName) { this.page = 1; this.filterLoading = true; + const loadingDone = () => { + this.filterLoading = false; + }; this.loadIssues(true) - .then(() => { - this.filterLoading = false; - }); + .then(loadingDone) + .catch(loadingDone); } }, deep: true, diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 22f20305624..7e3bb79af1d 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -1,4 +1,5 @@ -/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ +/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var, + promise/catch-or-return */ window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 66384d9c038..ccb00099215 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -36,6 +36,9 @@ gl.issueBoards.BoardsStore = { .save() .then(() => { this.state.lists = _.sortBy(this.state.lists, 'position'); + }) + .catch(() => { + // https://gitlab.com/gitlab-org/gitlab-ce/issues/30821 }); this.removeBlankState(); }, diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 8fafd13c6c2..92f6fd654b3 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -64,6 +64,8 @@ const ResolveBtn = Vue.extend({ }); }, resolve: function () { + const errorFlashMsg = 'An error occurred when trying to resolve a comment. Please try again.'; + if (!this.canResolve) return; let promise; @@ -87,10 +89,12 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); } else { - new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); + new Flash(errorFlashMsg); } this.updateTooltip(); + }).catch(() => { + new Flash(errorFlashMsg); }); } }, diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index e1e2e3e93f9..4ea6ba8a73d 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -51,8 +51,10 @@ class ResolveServiceClass { discussion.updateHeadline(data); } else { - new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); + throw new Error('An error occurred when trying to resolve discussion.'); } + }).catch(() => { + new Flash('An error occurred when trying to resolve a discussion. Please try again.'); }); } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index d88b7cd0e17..02a7df9b2a0 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -150,13 +150,13 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:milestones:new': case 'projects:milestones:edit': case 'projects:milestones:update': + case 'groups:milestones:new': + case 'groups:milestones:edit': + case 'groups:milestones:update': new ZenMode(); new gl.DueDateSelectors(); new gl.GLForm($('.milestone-form')); break; - case 'groups:milestones:new': - new ZenMode(); - break; case 'projects:compare:show': new gl.Diff(); break; @@ -367,6 +367,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'admin': new Admin(); switch (path[1]) { + case 'cohorts': + new gl.UsagePing(); + break; case 'groups': new UsersSelect(); break; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index a23d914772a..8883ed9aa14 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -2,10 +2,12 @@ const DATA_TRIGGER = 'data-dropdown-trigger'; const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; +const IGNORE_CLASS = 'droplab-item-ignore'; export { DATA_TRIGGER, DATA_DROPDOWN, SELECTED_CLASS, ACTIVE_CLASS, + IGNORE_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 9588921ebcd..1fb4d63923c 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,7 +1,7 @@ /* eslint-disable */ import utils from './utils'; -import { SELECTED_CLASS } from './constants'; +import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; var DropDown = function(list) { this.currentIndex = 0; @@ -36,6 +36,7 @@ Object.assign(DropDown.prototype, { clickEvent: function(e) { if (e.target.tagName === 'UL') return; + if (e.target.classList.contains(IGNORE_CLASS)) return; var selected = utils.closest(e.target, 'LI'); if (!selected) return; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index c5fbbdaf465..b70d242269d 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -38,6 +38,9 @@ window.DropzoneInput = (function() { "opacity": 0, "display": "none" }); + + if (!project_uploads_path) return; + dropzone = form_dropzone.dropzone({ url: project_uploads_path, dictDefaultMessage: "", diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index db10b383913..dd95b6530f6 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -115,11 +115,13 @@ class DueDateSelect { this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); this.$value.css('display', ''); + const fadeOutLoader = () => { + this.$loading.fadeOut(); + }; gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) - .then(() => { - this.$loading.fadeOut(); - }); + .then(fadeOutLoader) + .catch(fadeOutLoader); } submitSelectedDate(isDropdown) { diff --git a/app/assets/javascripts/environments/components/environment_actions.js b/app/assets/javascripts/environments/components/environment_actions.js index 1418e8d86ee..313e78e573a 100644 --- a/app/assets/javascripts/environments/components/environment_actions.js +++ b/app/assets/javascripts/environments/components/environment_actions.js @@ -35,6 +35,8 @@ export default { onClickAction(endpoint) { this.isLoading = true; + $(this.$refs.tooltip).tooltip('destroy'); + this.service.postAction(endpoint) .then(() => { this.isLoading = false; @@ -62,6 +64,7 @@ export default { class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip" data-container="body" data-toggle="dropdown" + ref="tooltip" :title="title" :aria-label="title" :disabled="isLoading"> diff --git a/app/assets/javascripts/environments/components/environment_external_url.js b/app/assets/javascripts/environments/components/environment_external_url.js deleted file mode 100644 index d79b916c360..00000000000 --- a/app/assets/javascripts/environments/components/environment_external_url.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Renders the external url link in environments table. - */ -export default { - props: { - externalUrl: { - type: String, - default: '', - }, - }, - - computed: { - title() { - return 'Open'; - }, - }, - - template: ` - <a - class="btn external-url has-tooltip" - data-container="body" - :href="externalUrl" - target="_blank" - rel="noopener noreferrer nofollow" - :title="title" - :aria-label="title"> - <i class="fa fa-external-link" aria-hidden="true"></i> - </a> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue new file mode 100644 index 00000000000..eaeec2bc53c --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -0,0 +1,33 @@ +<script> +/** + * Renders the external url link in environments table. + */ +export default { + props: { + externalUrl: { + type: String, + required: true, + }, + }, + + computed: { + title() { + return 'Open'; + }, + }, +}; +</script> +<template> + <a + class="btn external-url has-tooltip" + data-container="body" + target="_blank" + rel="noopener noreferrer nofollow" + :title="title" + :aria-label="title" + :href="externalUrl"> + <i + class="fa fa-external-link" + aria-hidden="true" /> + </a> +</template> diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.js index d9b49287dec..0b174cf97da 100644 --- a/app/assets/javascripts/environments/components/environment_item.js +++ b/app/assets/javascripts/environments/components/environment_item.js @@ -1,11 +1,11 @@ import Timeago from 'timeago.js'; import '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions'; -import ExternalUrlComponent from './environment_external_url'; -import StopComponent from './environment_stop'; -import RollbackComponent from './environment_rollback'; -import TerminalButtonComponent from './environment_terminal_button'; -import MonitoringButtonComponent from './environment_monitoring'; +import ExternalUrlComponent from './environment_external_url.vue'; +import StopComponent from './environment_stop.vue'; +import RollbackComponent from './environment_rollback.vue'; +import TerminalButtonComponent from './environment_terminal_button.vue'; +import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/environments/components/environment_monitoring.js b/app/assets/javascripts/environments/components/environment_monitoring.js deleted file mode 100644 index 064e2fc7434..00000000000 --- a/app/assets/javascripts/environments/components/environment_monitoring.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Renders the Monitoring (Metrics) link in environments table. - */ -export default { - props: { - monitoringUrl: { - type: String, - default: '', - required: true, - }, - }, - - computed: { - title() { - return 'Monitoring'; - }, - }, - - template: ` - <a - class="btn monitoring-url has-tooltip" - data-container="body" - :href="monitoringUrl" - target="_blank" - rel="noopener noreferrer nofollow" - :title="title" - :aria-label="title"> - <i class="fa fa-area-chart" aria-hidden="true"></i> - </a> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue new file mode 100644 index 00000000000..4b030a27900 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -0,0 +1,33 @@ +<script> +/** + * Renders the Monitoring (Metrics) link in environments table. + */ +export default { + props: { + monitoringUrl: { + type: String, + required: true, + }, + }, + + computed: { + title() { + return 'Monitoring'; + }, + }, +}; +</script> +<template> + <a + class="btn monitoring-url has-tooltip" + data-container="body" + target="_blank" + rel="noopener noreferrer nofollow" + :href="monitoringUrl" + :title="title" + :aria-label="title"> + <i + class="fa fa-area-chart" + aria-hidden="true" /> + </a> +</template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.js b/app/assets/javascripts/environments/components/environment_rollback.vue index baa15d9e5b5..f139f24036f 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.js +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -1,3 +1,4 @@ +<script> /* global Flash */ /* eslint-disable no-new */ /** @@ -36,6 +37,8 @@ export default { onClick() { this.isLoading = true; + $(this.$el).tooltip('destroy'); + this.service.postAction(this.retryUrl) .then(() => { this.isLoading = false; @@ -47,21 +50,25 @@ export default { }); }, }, +}; +</script> +<template> + <button + type="button" + class="btn" + @click="onClick" + :disabled="isLoading"> - template: ` - <button type="button" - class="btn" - @click="onClick" - :disabled="isLoading"> - - <span v-if="isLastDeployment"> - Re-deploy - </span> - <span v-else> - Rollback - </span> + <span v-if="isLastDeployment"> + Re-deploy + </span> + <span v-else> + Rollback + </span> - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </button> - `, -}; + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + </button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_stop.js b/app/assets/javascripts/environments/components/environment_stop.vue index 47102692024..11e9aff7b92 100644 --- a/app/assets/javascripts/environments/components/environment_stop.js +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -1,3 +1,4 @@ +<script> /* global Flash */ /* eslint-disable no-new, no-alert */ /** @@ -36,6 +37,8 @@ export default { if (confirm('Are you sure you want to stop this environment?')) { this.isLoading = true; + $(this.$el).tooltip('destroy'); + this.service.postAction(this.retryUrl) .then(() => { this.isLoading = false; @@ -48,17 +51,23 @@ export default { } }, }, - - template: ` - <button type="button" - class="btn stop-env-link has-tooltip" - data-container="body" - @click="onClick" - :disabled="isLoading" - :title="title" - :aria-label="title"> - <i class="fa fa-stop stop-env-icon" aria-hidden="true"></i> - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </button> - `, }; +</script> +<template> + <button + type="button" + class="btn stop-env-link has-tooltip" + data-container="body" + @click="onClick" + :disabled="isLoading" + :title="title" + :aria-label="title"> + <i + class="fa fa-stop stop-env-icon" + aria-hidden="true" /> + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + </button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.js b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 092a50a0d6f..c8c1f17d4d8 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.js +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -1,3 +1,4 @@ +<script> /** * Renders a terminal button to open a web terminal. * Used in environments table. @@ -24,14 +25,15 @@ export default { return 'Terminal'; }, }, - - template: ` - <a class="btn terminal-button has-tooltip" - data-container="body" - :title="title" - :aria-label="title" - :href="terminalPath"> - ${terminalIconSvg} - </a> - `, }; +</script> +<template> + <a + class="btn terminal-button has-tooltip" + data-container="body" + :title="title" + :aria-label="title" + :href="terminalPath" + v-html="terminalIconSvg"> + </a> +</template> diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index a5eb33dd9de..68a832102a0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -343,6 +343,8 @@ class FilteredSearchManager { const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery); this.recentSearchesService.save(resultantSearches); } + }).catch(() => { + // https://gitlab.com/gitlab-org/gitlab-ce/issues/30821 }); } diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 10363c16bae..acfa4bd4c6b 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,4 +1,8 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var, camelcase, one-var-declaration-per-line, quotes, object-shorthand, prefer-arrow-callback, comma-dangle, consistent-return, yoda, prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, max-len */ +/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var, + camelcase, one-var-declaration-per-line, quotes, object-shorthand, + prefer-arrow-callback, comma-dangle, consistent-return, yoda, + prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, + promise/catch-or-return */ /* global Api */ var slice = [].slice; diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue index ba54178a310..00b0e56030a 100644 --- a/app/assets/javascripts/issue_show/issue_title.vue +++ b/app/assets/javascripts/issue_show/issue_title.vue @@ -34,17 +34,6 @@ export default { }; }, methods: { - fetch() { - this.poll.makeRequest(); - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, renderResponse(res) { const body = JSON.parse(res.body); this.triggerAnimation(body); @@ -71,7 +60,17 @@ export default { }, }, created() { - this.fetch(); + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); }, }; </script> diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 443fb3e0ca9..9a60f5464df 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -332,6 +332,9 @@ vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(label, $el, e, isMarking) { var isIssueIndex, isMRIndex, page, boardsModel; + var fadeOutLoader = () => { + $loading.fadeOut(); + }; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -396,9 +399,8 @@ $loading.fadeIn(); gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }); + .then(fadeOutLoader) + .catch(fadeOutLoader); } else { if ($dropdown.hasClass('js-multiselect')) { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c50ec24c818..be3c2c9fbb1 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -165,6 +165,7 @@ import './syntax_highlight'; import './task_list'; import './todos'; import './tree'; +import './usage_ping'; import './user'; import './user_tabs'; import './username_validator'; @@ -210,6 +211,14 @@ $(function () { } }); + if (bootstrapBreakpoint === 'xs') { + const $rightSidebar = $('aside.right-sidebar, .page-with-sidebar'); + + $rightSidebar + .removeClass('right-sidebar-expanded') + .addClass('right-sidebar-collapsed'); + } + // prevent default action for disabled buttons $('.btn').click(function(e) { if ($(this).hasClass('disabled')) { diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index b0254b17dd2..42ecf0d6cb2 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -157,7 +157,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; $('.ci-widget-fetching').show(); return $.getJSON(this.opts.ci_status_url, (function(_this) { return function(data) { - var message, status, title; + var message, status, title, callback; _this.status = data.status; _this.hasCi = data.has_ci; _this.updateMergeButton(_this.status, _this.hasCi); @@ -179,6 +179,12 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; _this.opts.ci_sha = data.sha; _this.updateCommitUrls(data.sha); } + if (data.status === "success" || data.status === "failed") { + callback = function() { + return _this.getMergeStatus(); + }; + return setTimeout(callback, 2000); + } if (showNotification && data.status) { status = _this.ciLabelForStatus(data.status); if (status === "preparing") { diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 773fe3233a7..bebd0aa357e 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -164,6 +164,9 @@ .then(function () { $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); + }) + .catch(() => { + $loading.fadeOut(); }); } else { selected = $selectbox.find('input[type="hidden"]').val(); diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index d82a4eb9642..aff507abb91 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -71,6 +71,8 @@ class PrometheusGraph { this.transformData(metricsResponse); this.createGraph(); } + }).catch(() => { + new Flash('An error occurred when trying to load metrics. Please try again.'); }); } diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 15f7a813626..974fb0d83da 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -308,8 +308,10 @@ require('./task_list'); if (this.isNewNote(note)) { this.note_ids.push(note.id); - $notesList = $('ul.main-notes-list'); - $notesList.append(note.html).syntaxHighlight(); + + $notesList = window.$('ul.main-notes-list'); + Notes.animateAppendNote(note.html, $notesList); + // Update datetime format on the recent note gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); this.collapseLongCommitList(); @@ -348,7 +350,7 @@ require('./task_list'); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); + discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } @@ -370,14 +372,13 @@ require('./task_list'); row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); } } - // Init discussion on 'Discussion' page if it is merge request page - if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { - $('ul.main-notes-list').append($(note.discussion_html).renderGFM()); + if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { + Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list')); } } else { // append new note to all matching discussions - discussionContainer.append($(note.html).renderGFM()); + Notes.animateAppendNote(note.html, discussionContainer); } if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { @@ -1063,6 +1064,13 @@ require('./task_list'); return $form; }; + Notes.animateAppendNote = function(noteHTML, $notesList) { + const $note = window.$(noteHTML); + + $note.addClass('fade-in').renderGFM(); + $notesList.append($note); + }; + return Notes; })(); }).call(window); diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index 11da6e908b7..d1c60b570de 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -65,6 +65,8 @@ export default { makeRequest() { this.isLoading = true; + $(this.$el).tooltip('destroy'); + this.service.postAction(this.endpoint) .then(() => { this.isLoading = false; @@ -88,9 +90,13 @@ export default { :aria-label="title" data-container="body" data-placement="top" - :disabled="isLoading" - > - <i :class="iconClass" aria-hidden="true"></i> - <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading"></i> + :disabled="isLoading"> + <i + :class="iconClass" + aria-hidden="true" /> + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="isLoading" /> </button> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js index 12d80768646..ffda18d2e0f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.js +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -28,6 +28,8 @@ export default { onClickAction(endpoint) { this.isLoading = true; + $(this.$refs.tooltip).tooltip('destroy'); + this.service.postAction(endpoint) .then(() => { this.isLoading = false; @@ -57,6 +59,7 @@ export default { data-toggle="dropdown" data-placement="top" aria-label="Manual job" + ref="tooltip" :disabled="isLoading"> ${playIconSvg} <i diff --git a/app/assets/javascripts/usage_ping.js b/app/assets/javascripts/usage_ping.js new file mode 100644 index 00000000000..fd3af7d7ab6 --- /dev/null +++ b/app/assets/javascripts/usage_ping.js @@ -0,0 +1,15 @@ +function UsagePing() { + const usageDataUrl = $('.usage-data').data('endpoint'); + + $.ajax({ + type: 'GET', + url: usageDataUrl, + dataType: 'html', + success(html) { + $('.usage-data').html(html); + }, + }); +} + +window.gl = window.gl || {}; +window.gl.UsagePing = UsagePing; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 3325a7d429c..30902767705 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -56,6 +56,9 @@ gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) .then(function () { $loading.fadeOut(); + }) + .catch(function () { + $loading.fadeOut(); }); }; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 90935b9616b..7c50b80fd2b 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -145,3 +145,17 @@ a { .dropdown-menu-nav a { transition: none; } + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.fade-in { + animation: fadeIn $fade-in-duration 1; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 2c33b235980..0fd7203e72b 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -40,6 +40,10 @@ line-height: 24px; } +.bold { + font-weight: 600; +} + .tab-content { overflow: visible; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 7767826b033..b87e712c763 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -564,3 +564,7 @@ color: $gl-text-color-secondary; } } + +.droplab-item-ignore { + pointer-events: none; +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 2071d4dcfce..0077ea41d3b 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -331,6 +331,14 @@ header { .dropdown-menu-nav { min-width: 140px; margin-top: -5px; + + .current-user { + padding: 5px 18px; + + .user-name { + display: block; + } + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 20ef9a774e4..3ef6ec3f912 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -458,6 +458,11 @@ $label-remove-border: rgba(0, 0, 0, .1); $label-border-radius: 100px; /* +* Animation +*/ +$fade-in-duration: 200ms; + +/* * Lint */ $lint-incorrect-color: $red-500; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0bca3e93e4c..8d3d1a72b9b 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -210,10 +210,6 @@ } } - .bold { - font-weight: 600; - } - .light { font-weight: normal; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index a9e2a034790..28a8f9cb335 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -596,6 +596,10 @@ pre.light-well { .avatar-container { align-self: flex-start; + + > a { + width: 100%; + } } .project-details { diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 515d8e1523b..643993d035e 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -17,6 +17,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end end + def usage_data + respond_to do |format| + format.html do + usage_data = Gitlab::UsageData.data + usage_data_json = params[:pretty] ? JSON.pretty_generate(usage_data) : usage_data.to_json + + render html: Gitlab::Highlight.highlight('payload.json', usage_data_json) + end + format.json { render json: Gitlab::UsageData.to_json } + end + end + def reset_runners_token @application_setting.reset_runners_registration_token! flash[:notice] = 'New runners registration token has been generated!' @@ -135,6 +147,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :version_check_enabled, :terminal_max_session_time, :polling_interval_multiplier, + :usage_ping_enabled, disabled_oauth_sign_in_sources: [], import_sources: [], diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb new file mode 100644 index 00000000000..9b77c554908 --- /dev/null +++ b/app/controllers/admin/cohorts_controller.rb @@ -0,0 +1,11 @@ +class Admin::CohortsController < Admin::ApplicationController + def index + if current_application_settings.usage_ping_enabled + cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do + CohortsService.new.execute + end + + @cohorts = CohortsSerializer.new.represent(cohorts_results) + end + end +end diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 9ac8197e45a..183eb00ef67 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -1,17 +1,29 @@ module CreatesCommit extend ActiveSupport::Concern + def set_start_branch_to_branch_name + branch_exists = @repository.find_branch(@branch_name) + @start_branch = @branch_name if branch_exists + end + def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) - set_commit_variables + if can?(current_user, :push_code, @project) + @project_to_commit_into = @project + @branch_name ||= @ref + else + @project_to_commit_into = current_user.fork_of(@project) + @branch_name ||= @project_to_commit_into.repository.next_branch('patch') + end + + @start_branch ||= @ref || @branch_name commit_params = @commit_params.merge( - start_project: @mr_target_project, - start_branch: @mr_target_branch, - target_branch: @mr_source_branch + start_project: @project, + start_branch: @start_branch, + branch_name: @branch_name ) - result = service.new( - @mr_source_project, current_user, commit_params).execute + result = service.new(@project_to_commit_into, current_user, commit_params).execute if result[:status] == :success update_flash_notice(success_notice) @@ -72,30 +84,30 @@ module CreatesCommit def new_merge_request_path new_namespace_project_merge_request_path( - @mr_source_project.namespace, - @mr_source_project, + @project_to_commit_into.namespace, + @project_to_commit_into, merge_request: { - source_project_id: @mr_source_project.id, - target_project_id: @mr_target_project.id, - source_branch: @mr_source_branch, - target_branch: @mr_target_branch + source_project_id: @project_to_commit_into.id, + target_project_id: @project.id, + source_branch: @branch_name, + target_branch: @start_branch } ) end def existing_merge_request_path - namespace_project_merge_request_path(@mr_target_project.namespace, @mr_target_project, @merge_request) + namespace_project_merge_request_path(@project.namespace, @project, @merge_request) end def merge_request_exists? return @merge_request if defined?(@merge_request) - @merge_request = MergeRequestsFinder.new(current_user, project_id: @mr_target_project.id).execute.opened. - find_by(source_branch: @mr_source_branch, target_branch: @mr_target_branch, source_project_id: @mr_source_project) + @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened. + find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch) end def different_project? - @mr_source_project != @mr_target_project + @project_to_commit_into != @project end def create_merge_request? @@ -103,22 +115,6 @@ module CreatesCommit # as the target branch in the same project, # we don't want to create a merge request. params[:create_merge_request].present? && - (different_project? || @mr_target_branch != @mr_source_branch) - end - - def set_commit_variables - if can?(current_user, :push_code, @project) - @mr_source_project = @project - @target_branch ||= @ref - else - @mr_source_project = current_user.fork_of(@project) - @target_branch ||= @mr_source_project.repository.next_branch('patch') - end - - # Merge request to this project - @mr_target_project = @project - @mr_target_branch ||= @ref || @target_branch - - @mr_source_branch = @target_branch + (different_project? || @start_branch != @branch_name) end end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index c13333641d3..b1bacc8ffe5 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -1,6 +1,32 @@ module MembershipActions extend ActiveSupport::Concern + def create + status = Members::CreateService.new(membershipable, current_user, params).execute + + redirect_url = members_page_url + + if status + redirect_to redirect_url, notice: 'Users were successfully added.' + else + redirect_to redirect_url, alert: 'No users specified.' + end + end + + def destroy + Members::DestroyService.new(membershipable, current_user, params). + execute(:all) + + respond_to do |format| + format.html do + message = "User was successfully removed from #{source_type}." + redirect_to members_page_url, notice: message + end + + format.js { head :ok } + end + end + def request_access membershipable.request_access(current_user) @@ -11,20 +37,20 @@ module MembershipActions def approve_access_request Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute - redirect_to polymorphic_url([membershipable, :members]) + redirect_to members_page_url end def leave member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id). execute(:all) - source_type = membershipable.class.to_s.humanize(capitalize: false) notice = if member.request? "Your access request to the #{source_type} has been withdrawn." else "You left the \"#{membershipable.human_name}\" #{source_type}." end + redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize] redirect_to redirect_path, notice: notice @@ -35,4 +61,16 @@ module MembershipActions def membershipable raise NotImplementedError end + + def members_page_url + if membershipable.is_a?(Project) + project_settings_members_path(membershipable) + else + polymorphic_url([membershipable, :members]) + end + end + + def source_type + @source_type ||= membershipable.class.to_s.humanize(capitalize: false) + end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 00c50f9d0ad..8fc234a62b1 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -21,21 +21,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @group_member = @group.group_members.new end - def create - if params[:user_ids].blank? - return redirect_to(group_group_members_path(@group), alert: 'No users specified.') - end - - @group.add_users( - params[:user_ids].split(','), - params[:access_level], - current_user: current_user, - expires_at: params[:expires_at] - ) - - redirect_to group_group_members_path(@group), notice: 'Users were successfully added.' - end - def update @group_member = @group.group_members.find(params[:id]) @@ -44,15 +29,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @group_member.update_attributes(member_params) end - def destroy - Members::DestroyService.new(@group, current_user, id: params[:id]).execute(:all) - - respond_to do |format| - format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } - format.js { head :ok } - end - end - def resend_invite redirect_path = group_group_members_path(@group) diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index f1a93ccb3ad..e2f81b09adc 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -89,9 +89,4 @@ class Projects::ApplicationController < ApplicationController def builds_enabled return render_404 unless @project.feature_available?(:builds, current_user) end - - def update_ref - branch_exists = @repository.find_branch(@target_branch) - @ref = @target_branch if branch_exists - end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 73706bf8dae..9fce1db6742 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -25,10 +25,10 @@ class Projects::BlobController < Projects::ApplicationController end def create - update_ref + set_start_branch_to_branch_name create_commit(Files::CreateService, success_notice: "The file has been successfully created.", - success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @file_path)) }, + success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @file_path)) }, failure_view: :new, failure_path: namespace_project_new_blob_path(@project.namespace, @project, @ref)) end @@ -69,10 +69,10 @@ class Projects::BlobController < Projects::ApplicationController end def destroy - create_commit(Files::DestroyService, success_notice: "The file has been successfully deleted.", - success_path: -> { namespace_project_tree_path(@project.namespace, @project, @target_branch) }, - failure_view: :show, - failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.", + success_path: -> { namespace_project_tree_path(@project.namespace, @project, @branch_name) }, + failure_view: :show, + failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) end def diff @@ -127,16 +127,16 @@ class Projects::BlobController < Projects::ApplicationController def after_edit_path from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid]) - if from_merge_request && @target_branch == @ref + if from_merge_request && @branch_name == @ref diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) + "##{hexdigest(@path)}" else - namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @path)) + namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @path)) end end def editor_variables - @target_branch = params[:target_branch] + @branch_name = params[:branch_name] @file_path = if action_name.to_s == 'create' diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index d25bbddd1bb..2b5f0383ac1 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -56,9 +56,7 @@ class Projects::CommitController < Projects::ApplicationController return render_404 if @start_branch.blank? - @target_branch = create_new_branch? ? @commit.revert_branch_name : @start_branch - - @mr_target_branch = @start_branch + @branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.", success_path: -> { successful_change_path }, failure_path: failed_change_path) @@ -69,9 +67,7 @@ class Projects::CommitController < Projects::ApplicationController return render_404 if @start_branch.blank? - @target_branch = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch - - @mr_target_branch = @start_branch + @branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked.", success_path: -> { successful_change_path }, failure_path: failed_change_path) @@ -84,7 +80,7 @@ class Projects::CommitController < Projects::ApplicationController end def successful_change_path - referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @target_branch) + referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @branch_name) end def failed_change_path diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 37f6f637ff0..10adddb4636 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -5,6 +5,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) def info_refs if upload_pack? && upload_pack_allowed? + log_user_activity + render_ok elsif receive_pack? && receive_pack_allowed? render_ok @@ -106,4 +108,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController def access_klass @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess end + + def log_user_activity + Users::ActivityService.new(user, 'pull').execute + end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 6e158e685e9..d2d26738582 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -10,18 +10,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort) end - def create - status = Members::CreateService.new(@project, current_user, params).execute - - redirect_url = namespace_project_settings_members_path(@project.namespace, @project) - - if status - redirect_to redirect_url, notice: 'Users were successfully added.' - else - redirect_to redirect_url, alert: 'No users or groups specified.' - end - end - def update @project_member = @project.project_members.find(params[:id]) @@ -30,18 +18,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_member.update_attributes(member_params) end - def destroy - Members::DestroyService.new(@project, current_user, params). - execute(:all) - - respond_to do |format| - format.html do - redirect_to namespace_project_settings_members_path(@project.namespace, @project) - end - format.js { head :ok } - end - end - def resend_invite redirect_path = namespace_project_settings_members_path(@project.namespace, @project) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 637b61504d8..5e2182c883e 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -34,16 +34,16 @@ class Projects::TreeController < Projects::ApplicationController def create_dir return render_404 unless @commit_params.values.all? - update_ref + set_start_branch_to_branch_name create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.", - success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@target_branch, @dir_name)), + success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@branch_name, @dir_name)), failure_path: namespace_project_tree_path(@project.namespace, @project, @ref)) end private def assign_dir_vars - @target_branch = params[:target_branch] + @branch_name = params[:branch_name] @dir_name = File.join(@path, params[:dir_name]) @commit_params = { diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d3091a4f8e9..8c6ba4915cd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -35,6 +35,7 @@ class SessionsController < Devise::SessionsController # hide the signed-in notification flash[:notice] = nil log_audit_event(current_user, with: authentication_method) + log_user_activity(current_user) end end @@ -123,6 +124,10 @@ class SessionsController < Devise::SessionsController for_authentication.security_event end + def log_user_activity(user) + Users::ActivityService.new(user, 'login').execute + end + def load_recaptcha Gitlab::Recaptcha.load_configurations! end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index ec57fec4f99..0b13dbf5f8d 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -165,11 +165,8 @@ module IssuablesHelper html.html_safe end - def cached_assigned_issuables_count(assignee, issuable_type, state) - cache_key = hexdigest(['assigned_issuables_count', assignee.id, issuable_type, state].join('-')) - Rails.cache.fetch(cache_key, expires_in: 2.minutes) do - assigned_issuables_count(assignee, issuable_type, state) - end + def assigned_issuables_count(issuable_type) + current_user.public_send("assigned_open_#{issuable_type}_count") end def issuable_filter_params @@ -192,10 +189,6 @@ module IssuablesHelper private - def assigned_issuables_count(assignee, issuable_type, state) - assignee.public_send("assigned_#{issuable_type}").public_send(state).count - end - def sidebar_gutter_collapsed? cookies[:collapsed_gutter] == 'true' end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 43669b6f356..5f97e6114ea 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -272,14 +272,14 @@ module ProjectsHelper end end - def add_special_file_path(project, file_name:, commit_message: nil, target_branch: nil, context: nil) + def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil) namespace_project_new_blob_path( project.namespace, project, project.default_branch || 'master', file_name: file_name, commit_message: commit_message || "Add #{file_name.downcase}", - target_branch: target_branch, + branch_name: branch_name, context: context ) end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 3a5d1b97c36..2fda98cae90 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -62,6 +62,14 @@ module SortingHelper } end + def branches_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sort_title_priority 'Priority' end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 4a76c679bad..f1dab60524e 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -35,7 +35,7 @@ module TreeHelper end def on_top_of_branch?(project = @project, ref = @ref) - project.repository.branch_names.include?(ref) + project.repository.branch_exists?(ref) end def can_edit_tree?(project = nil, ref = nil) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 2961e16f5e0..dd1a6922968 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -238,7 +238,8 @@ class ApplicationSetting < ActiveRecord::Base terminal_max_session_time: 0, two_factor_grace_period: 48, user_default_external: false, - polling_interval_multiplier: 1 + polling_interval_multiplier: 1, + usage_ping_enabled: true } end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3d2258d5e3e..26dbf4d9570 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -23,7 +23,7 @@ module Issuable included do cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :description + cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" belongs_to :assignee, class_name: "User" diff --git a/app/models/identity.rb b/app/models/identity.rb index 3bacc450e6e..920a25932b4 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -7,6 +7,8 @@ class Identity < ActiveRecord::Base validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider } validates :user_id, uniqueness: { scope: :provider } + scope :with_extern_uid, ->(provider, extern_uid) { where(extern_uid: extern_uid, provider: provider) } + def ldap? provider.starts_with?('ldap') end diff --git a/app/models/note.rb b/app/models/note.rb index 630d0adbece..e720bfba030 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -16,7 +16,7 @@ class Note < ActiveRecord::Base ignore_column :original_discussion_id - cache_markdown_field :note, pipeline: :note + cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. diff --git a/app/models/project.rb b/app/models/project.rb index a160efba912..73593f04283 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -181,7 +181,7 @@ class Project < ActiveRecord::Base delegate :name, to: :owner, allow_nil: true, prefix: true delegate :count, to: :forks, prefix: true delegate :members, to: :team, prefix: true - delegate :add_user, to: :team + delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team delegate :empty_repo?, to: :repository diff --git a/app/models/user.rb b/app/models/user.rb index 2d85bf8df26..774d4caa806 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -99,9 +99,6 @@ class User < ActiveRecord::Base has_many :award_emoji, dependent: :destroy has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id - has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" - has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" - # Issues that a user owns are expected to be moved to the "ghost" user before # the user is destroyed. If the user owns any issues during deletion, this # should be treated as an exceptional condition. @@ -891,20 +888,20 @@ class User < ActiveRecord::Base @global_notification_setting end - def assigned_open_merge_request_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_request_count'], force: force) do - assigned_merge_requests.opened.count + def assigned_open_merge_requests_count(force: false) + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do + MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def assigned_open_issues_count(force: false) Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do - assigned_issues.opened.count + IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def update_cache_counts - assigned_open_merge_request_count(force: true) + assigned_open_merge_requests_count(force: true) assigned_open_issues_count(force: true) end diff --git a/app/serializers/cohort_activity_month_entity.rb b/app/serializers/cohort_activity_month_entity.rb new file mode 100644 index 00000000000..e6788a8b596 --- /dev/null +++ b/app/serializers/cohort_activity_month_entity.rb @@ -0,0 +1,11 @@ +class CohortActivityMonthEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + + expose :total do |cohort_activity_month| + number_with_delimiter(cohort_activity_month[:total]) + end + + expose :percentage do |cohort_activity_month| + number_to_percentage(cohort_activity_month[:percentage], precision: 0) + end +end diff --git a/app/serializers/cohort_entity.rb b/app/serializers/cohort_entity.rb new file mode 100644 index 00000000000..7cdba5b0484 --- /dev/null +++ b/app/serializers/cohort_entity.rb @@ -0,0 +1,17 @@ +class CohortEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + + expose :registration_month do |cohort| + cohort[:registration_month].strftime('%b %Y') + end + + expose :total do |cohort| + number_with_delimiter(cohort[:total]) + end + + expose :inactive do |cohort| + number_with_delimiter(cohort[:inactive]) + end + + expose :activity_months, using: CohortActivityMonthEntity +end diff --git a/app/serializers/cohorts_entity.rb b/app/serializers/cohorts_entity.rb new file mode 100644 index 00000000000..98f5995ba6f --- /dev/null +++ b/app/serializers/cohorts_entity.rb @@ -0,0 +1,4 @@ +class CohortsEntity < Grape::Entity + expose :months_included + expose :cohorts, using: CohortEntity +end diff --git a/app/serializers/cohorts_serializer.rb b/app/serializers/cohorts_serializer.rb new file mode 100644 index 00000000000..fe9367b13d8 --- /dev/null +++ b/app/serializers/cohorts_serializer.rb @@ -0,0 +1,3 @@ +class CohortsSerializer < AnalyticsGenericSerializer + entity CohortsEntity +end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb new file mode 100644 index 00000000000..6781533af28 --- /dev/null +++ b/app/services/cohorts_service.rb @@ -0,0 +1,100 @@ +class CohortsService + MONTHS_INCLUDED = 12 + + def execute + { + months_included: MONTHS_INCLUDED, + cohorts: cohorts + } + end + + # Get an array of hashes that looks like: + # + # [ + # { + # registration_month: Date.new(2017, 3), + # activity_months: [3, 2, 1], + # total: 3 + # inactive: 0 + # }, + # etc. + # + # The `months` array is always from oldest to newest, so it's always + # non-strictly decreasing from left to right. + def cohorts + months = Array.new(MONTHS_INCLUDED) { |i| i.months.ago.beginning_of_month.to_date } + + Array.new(MONTHS_INCLUDED) do + registration_month = months.last + activity_months = running_totals(months, registration_month) + + # Even if no users registered in this month, we always want to have a + # value to fill in the table. + inactive = counts_by_month[[registration_month, nil]].to_i + + months.pop + + { + registration_month: registration_month, + activity_months: activity_months, + total: activity_months.first[:total], + inactive: inactive + } + end + end + + private + + # Calculate a running sum of active users, so users active in later months + # count as active in this month, too. Start with the most recent month first, + # for calculating the running totals, and then reverse for displaying in the + # table. + # + # Each month has a total, and a percentage of the overall total, as keys. + def running_totals(all_months, registration_month) + month_totals = + all_months + .map { |activity_month| counts_by_month[[registration_month, activity_month]] } + .reduce([]) { |result, total| result << result.last.to_i + total.to_i } + .reverse + + overall_total = month_totals.first + + month_totals.map do |total| + { total: total, percentage: total.zero? ? 0 : 100 * total / overall_total } + end + end + + # Get a hash that looks like: + # + # { + # [created_at_month, last_activity_on_month] => count, + # [created_at_month, last_activity_on_month_2] => count_2, + # # etc. + # } + # + # created_at_month can never be nil, but last_activity_on_month can (when a + # user has never logged in, just been created). This covers the last + # MONTHS_INCLUDED months. + def counts_by_month + @counts_by_month ||= + begin + created_at_month = column_to_date('created_at') + last_activity_on_month = column_to_date('last_activity_on') + + User + .where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month) + .group(created_at_month, last_activity_on_month) + .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC") + .count + end + end + + def column_to_date(column) + if Gitlab::Database.postgresql? + "CAST(DATE_TRUNC('month', #{column}) AS date)" + else + "STR_TO_DATE(DATE_FORMAT(#{column}, '%Y-%m-01'), '%Y-%m-%d')" + end + end +end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 1297a792259..a48d6a976f0 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -1,69 +1,27 @@ module Commits - class ChangeService < ::BaseService - ValidationError = Class.new(StandardError) - ChangeError = Class.new(StandardError) + class ChangeService < Commits::CreateService + def initialize(*args) + super - def execute - @start_project = params[:start_project] || @project - @start_branch = params[:start_branch] - @target_branch = params[:target_branch] @commit = params[:commit] - - check_push_permissions - - commit - rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, - ValidationError, ChangeError => ex - error(ex.message) end private - def commit - raise NotImplementedError - end - def commit_change(action) raise NotImplementedError unless repository.respond_to?(action) - validate_target_branch if different_branch? - repository.public_send( action, current_user, @commit, - @target_branch, + @branch_name, start_project: @start_project, start_branch_name: @start_branch) - - success rescue Repository::CreateTreeError error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. - A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content." + This #{@commit.change_type_title(current_user)} may already have been #{action.to_s.dasherize}ed, or a more recent commit may have updated some of its content." raise ChangeError, error_msg end - - def check_push_permissions - allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@target_branch) - - unless allowed - raise ValidationError.new('You are not allowed to push into this branch') - end - - true - end - - def validate_target_branch - result = ValidateNewBranchService.new(@project, current_user) - .execute(@target_branch) - - if result[:status] == :error - raise ChangeError, "There was an error creating the source branch: #{result[:message]}" - end - end - - def different_branch? - @start_branch != @target_branch || @start_project != @project - end end end diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb index 605cca36f9c..320e229560d 100644 --- a/app/services/commits/cherry_pick_service.rb +++ b/app/services/commits/cherry_pick_service.rb @@ -1,6 +1,6 @@ module Commits class CherryPickService < ChangeService - def commit + def create_commit! commit_change(:cherry_pick) end end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb new file mode 100644 index 00000000000..c58f04a252b --- /dev/null +++ b/app/services/commits/create_service.rb @@ -0,0 +1,74 @@ +module Commits + class CreateService < ::BaseService + ValidationError = Class.new(StandardError) + ChangeError = Class.new(StandardError) + + def initialize(*args) + super + + @start_project = params[:start_project] || @project + @start_branch = params[:start_branch] + @branch_name = params[:branch_name] + end + + def execute + validate! + + new_commit = create_commit! + + success(result: new_commit) + rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Repository::CommitError, GitHooksService::PreReceiveError => ex + error(ex.message) + end + + private + + def create_commit! + raise NotImplementedError + end + + def raise_error(message) + raise ValidationError, message + end + + def different_branch? + @start_branch != @branch_name || @start_project != @project + end + + def validate! + validate_permissions! + validate_on_branch! + validate_branch_existance! + + validate_new_branch_name! if different_branch? + end + + def validate_permissions! + allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@branch_name) + + unless allowed + raise_error("You are not allowed to push into this branch") + end + end + + def validate_on_branch! + if !@start_project.empty_repo? && !@start_project.repository.branch_exists?(@start_branch) + raise_error('You can only create or edit files when you are on a branch') + end + end + + def validate_branch_existance! + if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) + raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes") + end + end + + def validate_new_branch_name! + result = ValidateNewBranchService.new(project, current_user).execute(@branch_name) + + if result[:status] == :error + raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}") + end + end + end +end diff --git a/app/services/commits/revert_service.rb b/app/services/commits/revert_service.rb index addd55cb32f..dc27399e047 100644 --- a/app/services/commits/revert_service.rb +++ b/app/services/commits/revert_service.rb @@ -1,6 +1,6 @@ module Commits class RevertService < ChangeService - def commit + def create_commit! commit_change(:revert) end end diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb index 1b5623baebe..3b611588466 100644 --- a/app/services/delete_merged_branches_service.rb +++ b/app/services/delete_merged_branches_service.rb @@ -8,9 +8,20 @@ class DeleteMergedBranchesService < BaseService branches = project.repository.branch_names branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) } + # Prevent deletion of branches relevant to open merge requests + branches -= merge_request_branch_names branches.each do |branch| DeleteBranchService.new(project, current_user).execute(branch) end end + + private + + def merge_request_branch_names + # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY + source_names = project.origin_merge_requests.opened.reorder(nil).uniq.pluck(:source_branch) + target_names = project.merge_requests.opened.reorder(nil).uniq.pluck(:target_branch) + (source_names + target_names).uniq + end end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index e24cc66e0fe..0f3a485a3fd 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -72,6 +72,8 @@ class EventCreateService def push(project, current_user, push_data) create_event(project, current_user, Event::PUSHED, data: push_data) + + Users::ActivityService.new(current_user, 'push').execute end private diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c8a60422bf4..38231f66009 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -1,79 +1,17 @@ module Files - class BaseService < ::BaseService - ValidationError = Class.new(StandardError) - - def execute - @start_project = params[:start_project] || @project - @start_branch = params[:start_branch] - @target_branch = params[:target_branch] + class BaseService < Commits::CreateService + def initialize(*args) + super + @author_email = params[:author_email] + @author_name = params[:author_name] @commit_message = params[:commit_message] - @file_path = params[:file_path] - @previous_path = params[:previous_path] - @file_content = if params[:file_content_encoding] == 'base64' - Base64.decode64(params[:file_content]) - else - params[:file_content] - end - @last_commit_sha = params[:last_commit_sha] - @author_email = params[:author_email] - @author_name = params[:author_name] - - # Validate parameters - validate - - # Create new branch if it different from start_branch - validate_target_branch if different_branch? - - result = commit - if result - success(result: result) - else - error('Something went wrong. Your changes were not committed') - end - rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, ValidationError => ex - error(ex.message) - end - - private - - def different_branch? - @start_branch != @target_branch || @start_project != @project - end - - def file_has_changed? - return false unless @last_commit_sha && last_commit - - @last_commit_sha != last_commit.sha - end - - def raise_error(message) - raise ValidationError.new(message) - end - - def validate - allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@target_branch) - - unless allowed - raise_error("You are not allowed to push into this branch") - end - - if !@start_project.empty_repo? && !@start_project.repository.branch_exists?(@start_branch) - raise ValidationError, 'You can only create or edit files when you are on a branch' - end - - if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) - raise ValidationError, "A branch called #{@branch_name} already exists. Switch to that branch in order to make changes" - end - end - def validate_target_branch - result = ValidateNewBranchService.new(project, current_user). - execute(@target_branch) + @file_path = params[:file_path] + @previous_path = params[:previous_path] - if result[:status] == :error - raise_error("Something went wrong when we tried to create #{@target_branch} for you: #{result[:message]}") - end + @file_content = params[:file_content] + @file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64' end end end diff --git a/app/services/files/create_dir_service.rb b/app/services/files/create_dir_service.rb index 083ffdc634c..8ecac6115bd 100644 --- a/app/services/files/create_dir_service.rb +++ b/app/services/files/create_dir_service.rb @@ -1,26 +1,15 @@ module Files class CreateDirService < Files::BaseService - def commit + def create_commit! repository.create_dir( current_user, @file_path, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, start_branch_name: @start_branch) end - - def validate - super - - unless @file_path =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file path ' + - Gitlab::Regex.file_path_regex_message - ) - end - end end end diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb index 65b5537fb68..00a8dcf0934 100644 --- a/app/services/files/create_service.rb +++ b/app/services/files/create_service.rb @@ -1,48 +1,16 @@ module Files class CreateService < Files::BaseService - def commit + def create_commit! repository.create_file( current_user, @file_path, @file_content, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, start_branch_name: @start_branch) end - - def validate - super - - if @file_content.nil? - raise_error("You must provide content.") - end - - if @file_path =~ Gitlab::Regex.directory_traversal_regex - raise_error( - 'Your changes could not be committed, because the file name ' + - Gitlab::Regex.directory_traversal_regex_message - ) - end - - unless @file_path =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file name ' + - Gitlab::Regex.file_path_regex_message - ) - end - - unless project.empty_repo? - @file_path.slice!(0) if @file_path.start_with?('/') - - blob = repository.blob_at_branch(@start_branch, @file_path) - - if blob - raise_error('Your changes could not be committed because a file with the same name already exists') - end - end - end end end diff --git a/app/services/files/destroy_service.rb b/app/services/files/delete_service.rb index e294659bc98..7952e5c95d4 100644 --- a/app/services/files/destroy_service.rb +++ b/app/services/files/delete_service.rb @@ -1,11 +1,11 @@ module Files - class DestroyService < Files::BaseService - def commit + class DeleteService < Files::BaseService + def create_commit! repository.delete_file( current_user, @file_path, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index 700f9f4f6f0..bfacc462847 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -1,14 +1,10 @@ module Files class MultiService < Files::BaseService - FileChangedError = Class.new(StandardError) - - ACTIONS = %w[create update delete move].freeze - - def commit + def create_commit! repository.multi_action( user: current_user, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, actions: params[:actions], author_email: @author_email, author_name: @author_name, @@ -19,122 +15,17 @@ module Files private - def validate + def validate! super - params[:actions].each_with_index do |action, index| - if ACTIONS.include?(action[:action].to_s) - action[:action] = action[:action].to_sym - else - raise_error("Unknown action type `#{action[:action]}`.") - end - - unless action[:file_path].present? - raise_error("You must specify a file_path.") - end - - action[:file_path].slice!(0) if action[:file_path] && action[:file_path].start_with?('/') - action[:previous_path].slice!(0) if action[:previous_path] && action[:previous_path].start_with?('/') - - regex_check(action[:file_path]) - regex_check(action[:previous_path]) if action[:previous_path] - - if project.empty_repo? && action[:action] != :create - raise_error("No files to #{action[:action]}.") - end - - validate_file_exists(action) - - case action[:action] - when :create - validate_create(action) - when :update - validate_update(action) - when :delete - validate_delete(action) - when :move - validate_move(action, index) - end - end - end - - def validate_file_exists(action) - return if action[:action] == :create - - file_path = action[:file_path] - file_path = action[:previous_path] if action[:action] == :move - - blob = repository.blob_at_branch(params[:branch], file_path) - - unless blob - raise_error("File to be #{action[:action]}d `#{file_path}` does not exist.") + params[:actions].each do |action| + validate_action!(action) end end - def last_commit - Gitlab::Git::Commit.last_for_path(repository, @start_branch, @file_path) - end - - def regex_check(file) - if file =~ Gitlab::Regex.directory_traversal_regex - raise_error( - 'Your changes could not be committed, because the file name, `' + - file + - '` ' + - Gitlab::Regex.directory_traversal_regex_message - ) - end - - unless file =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file name, `' + - file + - '` ' + - Gitlab::Regex.file_path_regex_message - ) - end - end - - def validate_create(action) - return if project.empty_repo? - - if repository.blob_at_branch(params[:branch], action[:file_path]) - raise_error("Your changes could not be committed because a file with the name `#{action[:file_path]}` already exists.") - end - - if action[:content].nil? - raise_error("You must provide content.") - end - end - - def validate_update(action) - if action[:content].nil? - raise_error("You must provide content.") - end - - if file_has_changed? - raise FileChangedError.new("You are attempting to update a file `#{action[:file_path]}` that has changed since you started editing it.") - end - end - - def validate_delete(action) - end - - def validate_move(action, index) - if action[:previous_path].nil? - raise_error("You must supply the original file path when moving file `#{action[:file_path]}`.") - end - - blob = repository.blob_at_branch(params[:branch], action[:file_path]) - - if blob - raise_error("Move destination `#{action[:file_path]}` already exists.") - end - - if action[:content].nil? - blob = repository.blob_at_branch(params[:branch], action[:previous_path]) - blob.load_all_data!(repository) if blob.truncated? - params[:actions][index][:content] = blob.data + def validate_action!(action) + unless Gitlab::Git::Index::ACTIONS.include?(action[:action].to_s) + raise_error("Unknown action '#{action[:action]}'") end end end diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index fbbab97632e..f23a9f6d57c 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,10 +2,16 @@ module Files class UpdateService < Files::BaseService FileChangedError = Class.new(StandardError) - def commit + def initialize(*args) + super + + @last_commit_sha = params[:last_commit_sha] + end + + def create_commit! repository.update_file(current_user, @file_path, @file_content, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, previous_path: @previous_path, author_email: @author_email, author_name: @author_name, @@ -15,21 +21,23 @@ module Files private - def validate - super - - if @file_content.nil? - raise_error("You must provide content.") - end + def file_has_changed? + return false unless @last_commit_sha && last_commit - if file_has_changed? - raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") - end + @last_commit_sha != last_commit.sha end def last_commit @last_commit ||= Gitlab::Git::Commit. last_for_path(@start_project.repository, @start_branch, @file_path) end + + def validate! + super + + if file_has_changed? + raise FileChangedError, "You are attempting to update a file that has changed since you started editing it." + end + end end end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index b7a244c2029..1711be7211c 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -9,7 +9,11 @@ module Members def execute return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user) - member.destroy + Member.transaction do + unassign_issues_and_merge_requests(member) + + member.destroy + end if member.request? && member.user != user notification_service.decline_access_request(member) @@ -17,5 +21,23 @@ module Members member end + + private + + def unassign_issues_and_merge_requests(member) + if member.is_a?(GroupMember) + IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute. + update_all(assignee_id: nil) + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute. + update_all(assignee_id: nil) + else + project = member.source + project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil) + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) + member.user.update_cache_counts + end + end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index e4b24ccef92..3a58f6c065d 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -1,9 +1,15 @@ module Members class CreateService < BaseService + def initialize(source, current_user, params = {}) + @source = source + @current_user = current_user + @params = params + end + def execute return false if params[:user_ids].blank? - project.team.add_users( + @source.add_users( params[:user_ids].split(','), params[:access_level], expires_at: params[:expires_at], diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb new file mode 100644 index 00000000000..facf21a7f5c --- /dev/null +++ b/app/services/users/activity_service.rb @@ -0,0 +1,22 @@ +module Users + class ActivityService + def initialize(author, activity) + @author = author.respond_to?(:user) ? author.user : author + @activity = activity + end + + def execute + return unless @author && @author.is_a?(User) + + record_activity + end + + private + + def record_activity + Gitlab::UserActivities.record(@author.id) + + Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}") + end + end +end diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb index 2f61be184ce..d232e85cd33 100644 --- a/app/services/validate_new_branch_service.rb +++ b/app/services/validate_new_branch_service.rb @@ -8,10 +8,7 @@ class ValidateNewBranchService < BaseService return error('Branch name is invalid') end - repository = project.repository - existing_branch = repository.find_branch(branch_name) - - if existing_branch + if project.repository.branch_exists?(branch_name) return error('Branch already exists') end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index f4ba44096d3..0dc1103eece 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -477,7 +477,7 @@ diagrams in Asciidoc documents using an external PlantUML service. %fieldset - %legend Usage statistics + %legend#usage-statistics Usage statistics .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -486,6 +486,19 @@ Version check enabled .help-block Let GitLab inform you when an update is available. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :usage_ping_enabled do + = f.check_box :usage_ping_enabled + Usage ping enabled + = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-data") + .help-block + Every week GitLab will report license usage back to GitLab, Inc. + Disable this option if you do not want this to occur. To see the + JSON payload that will be sent, visit the + = succeed '.' do + = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping') %fieldset %legend Email diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml new file mode 100644 index 00000000000..701a4e62b39 --- /dev/null +++ b/app/views/admin/cohorts/_cohorts_table.html.haml @@ -0,0 +1,28 @@ +.bs-callout.clearfix + %p + User cohorts are shown for the last #{@cohorts[:months_included]} + months. Only users with activity are counted in the cohort total; inactive + users are counted separately. + = link_to icon('question-circle'), help_page_path('user/admin_area/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank' + +.table-holder + %table.table + %thead + %tr + %th Registration month + %th Inactive users + %th Cohort total + - @cohorts[:months_included].times do |i| + %th Month #{i} + %tbody + - @cohorts[:cohorts].each do |cohort| + %tr + %td= cohort[:registration_month] + %td= cohort[:inactive] + %td= cohort[:total] + - cohort[:activity_months].each do |activity_month| + %td + - next if cohort[:total] == '0' + = activity_month[:percentage] + %br + = activity_month[:total] diff --git a/app/views/admin/cohorts/_usage_ping.html.haml b/app/views/admin/cohorts/_usage_ping.html.haml new file mode 100644 index 00000000000..73aa95d84f1 --- /dev/null +++ b/app/views/admin/cohorts/_usage_ping.html.haml @@ -0,0 +1,10 @@ +%h2#usage-ping Usage ping + +.bs-callout.clearfix + %p + User cohorts are shown because the usage ping is enabled. The data sent with + this is shown below. To disable this, visit + = succeed '.' do + = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics') + +%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html, pretty: true) } } diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/cohorts/index.html.haml new file mode 100644 index 00000000000..46fe12a5a99 --- /dev/null +++ b/app/views/admin/cohorts/index.html.haml @@ -0,0 +1,16 @@ +- @no_container = true += render "admin/dashboard/head" + +%div{ class: container_class } + - if @cohorts + = render 'cohorts_table' + = render 'usage_ping' + - else + .bs-callout.bs-callout-warning.clearfix + %p + User cohorts are only shown when the + = link_to 'usage ping', help_page_path('user/admin_area/usage_statistics'), target: '_blank' + is enabled. To enable it and see user cohorts, + visit + = succeed '.' do + = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics') diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 7893c1dee97..163bd5662b0 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -27,3 +27,7 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners + = nav_link path: 'cohorts#index' do + = link_to admin_cohorts_path, title: 'Cohorts' do + %span + Cohorts diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index a9893dea68f..659d548df18 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -47,13 +47,13 @@ %li = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('hashtag fw') - - issues_count = cached_assigned_issuables_count(current_user, :issues, :opened) + - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) %li = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - - merge_requests_count = cached_assigned_issuables_count(current_user, :merge_requests, :opened) + - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } = number_with_delimiter(merge_requests_count) %li @@ -67,6 +67,11 @@ = icon('caret-down') .dropdown-menu-nav.dropdown-menu-align-right %ul + %li.current-user + .user-name.bold + = current_user.name + @#{current_user.username} + %li.divider %li = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username } %li diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 444ecc414c0..ac222ad8c82 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -44,7 +44,7 @@ I %span Issues - .badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened)) + .badge= number_with_delimiter(assigned_issuables_count(:issues)) = nav_link(path: 'dashboard#merge_requests') do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do .shortcut-mappings @@ -53,7 +53,7 @@ M %span Merge Requests - .badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened)) + .badge= number_with_delimiter(assigned_issuables_count(:merge_requests)) = nav_link(controller: 'dashboard/snippets') do = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do .shortcut-mappings diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 4b26f944733..4af62461151 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -9,7 +9,7 @@ - if @conflict .alert.alert-danger Someone edited the file the same time you did. Please check out - = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank", rel: 'noopener noreferrer' + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs. .editor-title-row %h3.page-title.blob-edit-page-title diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index bd1f2d96f56..91b86280e4c 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -15,16 +15,14 @@ .dropdown.inline> %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.light - = projects_sort_options_hash[@sort] + = branches_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_branches_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_branches_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_branches_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - branches_sort_options_hash.each do |value, title| + %li + = link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value) - if can? current_user, :push_code, @project = link_to namespace_project_merged_branches_path(@project.namespace, @project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 2e54af698aa..766f119116f 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -13,9 +13,6 @@ Environment: = link_to @environment.name, environment_path(@environment) - .col-sm-6 - .nav-controls - = render 'projects/deployments/actions', deployment: @environment.last_deployment .prometheus-state .js-getting-started.hidden .row diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index bc426f1dc0c..0872a1a0503 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -19,6 +19,8 @@ = render 'projects/merge_requests/widget/open/conflicts' - elsif @merge_request.work_in_progress? = render 'projects/merge_requests/widget/open/wip' + - elsif @merge_request.merge_when_pipeline_succeeds? && @merge_request.merge_error.present? + = render 'projects/merge_requests/widget/open/error' - elsif @merge_request.merge_when_pipeline_succeeds? = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds' - elsif !@merge_request.can_be_merged_by?(current_user) diff --git a/app/views/projects/merge_requests/widget/open/_error.html.haml b/app/views/projects/merge_requests/widget/open/_error.html.haml new file mode 100644 index 00000000000..bbdc053609f --- /dev/null +++ b/app/views/projects/merge_requests/widget/open/_error.html.haml @@ -0,0 +1,6 @@ +%h4 + = icon('exclamation-triangle') + This merge request failed to be merged automatically + +%p + = @merge_request.merge_error diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index e57a76dbfd2..1e66c6079e3 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -5,7 +5,7 @@ %div{ class: container_class } %h3.page-title - Edit Milestone #{@milestone.to_reference} + Edit Milestone %hr diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index a173117984d..e8c9d7f8429 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -17,7 +17,7 @@ .header-text-content %span.identifier %strong - Milestone #{@milestone.to_reference} + Milestone - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml index 6bb55f04b6e..29cf5825292 100644 --- a/app/views/projects/notes/_comment_button.html.haml +++ b/app/views/projects/notes/_comment_button.html.haml @@ -16,7 +16,7 @@ %p Add a general comment to this #{noteable_name}. - %li.divider + %li.divider.droplab-item-ignore %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } } %a{ href: '#' } diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index fd7bd21677c..d6c4195e2d0 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -70,7 +70,7 @@ = link_to 'Set up Koding', add_koding_stack_path(@project) - if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present? %li.missing - = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', target_branch: 'auto-deploy', context: 'autodeploy') do + = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do Set up auto deploy - if @repository.commit diff --git a/app/views/shared/_branch_switcher.html.haml b/app/views/shared/_branch_switcher.html.haml index 7799aff6b5b..69e3f3042a9 100644 --- a/app/views/shared/_branch_switcher.html.haml +++ b/app/views/shared/_branch_switcher.html.haml @@ -1,8 +1,8 @@ -- dropdown_toggle_text = @target_branch || tree_edit_branch -= hidden_field_tag 'target_branch', dropdown_toggle_text +- dropdown_toggle_text = @branch_name || tree_edit_branch += hidden_field_tag 'branch_name', dropdown_toggle_text .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: 'dropdown', selected: dropdown_toggle_text, field_name: 'target_branch', form_id: '.js-edit-blob-form', refs_url: namespace_project_branches_path(@project.namespace, @project) }, { toggle_class: 'js-project-branches-dropdown js-target-branch' } + = dropdown_toggle dropdown_toggle_text, { toggle: 'dropdown', selected: dropdown_toggle_text, field_name: 'branch_name', form_id: '.js-edit-blob-form', refs_url: namespace_project_branches_path(@project.namespace, @project) }, { toggle_class: 'js-project-branches-dropdown js-target-branch' } .dropdown-menu.dropdown-menu-selectable.dropdown-menu-paging.dropdown-menu-branches = render partial: 'shared/projects/blob/branch_page_default' = render partial: 'shared/projects/blob/branch_page_create' diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 3ac5e15d1c4..0b37fe3013b 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -1,11 +1,11 @@ = render 'shared/commit_message_container', placeholder: placeholder - if @project.empty_repo? - = hidden_field_tag 'target_branch', @ref + = hidden_field_tag 'branch_name', @ref - else - if can?(current_user, :push_code, @project) .form-group.branch - = label_tag 'target_branch', 'Target branch', class: 'control-label' + = label_tag 'branch_name', 'Target branch', class: 'control-label' .col-sm-10 = render 'shared/branch_switcher' @@ -16,7 +16,7 @@ = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" Start a <strong>new merge request</strong> with these changes - else - = hidden_field_tag 'target_branch', @target_branch || tree_edit_branch + = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch = hidden_field_tag 'create_merge_request', 1 = hidden_field_tag 'original_branch', @ref, class: 'js-original-branch' diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 09f946f1d88..b361ec86ced 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -27,7 +27,8 @@ = visibility_level_icon(group.visibility_level, fw: false) .avatar-container.s40 - = image_tag group_icon(group), class: "avatar s40 hidden-xs" + = link_to group do + = image_tag group_icon(group), class: "avatar s40 hidden-xs" .title = link_to group_name, group, class: 'group-name' diff --git a/app/views/shared/icons/_icon_arrow_circle_o_right.svg b/app/views/shared/icons/_icon_arrow_circle_o_right.svg index db28b5e2d7a..5e45c6c15ce 100644 --- a/app/views/shared/icons/_icon_arrow_circle_o_right.svg +++ b/app/views/shared/icons/_icon_arrow_circle_o_right.svg @@ -1 +1 @@ -<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1280 896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-352q-13 0-22.5-9.5t-9.5-22.5v-192q0-13 9.5-22.5t22.5-9.5h352v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm160 0q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><g fill-rule="evenodd"><path fill-rule="nonzero" d="m0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7m1 0c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6"/><path d="m7 6h-2.702c-.154 0-.298.132-.298.295v1.41c0 .164.133.295.298.295h2.702v1.694c0 .18.095.209.213.09l2.539-2.568c.115-.116.118-.312 0-.432l-2.539-2.568c-.115-.116-.213-.079-.213.09v1.694"/></g></svg> diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 761f0b606b5..c3b40433c9a 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,10 +12,11 @@ = cache(cache_key) do - if avatar .avatar-container.s40 - - if use_creator_avatar - = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' - - else - = project_icon(project, alt: '', class: 'avatar project-avatar s40') + = link_to project_path(project), class: dom_class(project) do + - if use_creator_avatar + = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' + - else + = project_icon(project, alt: '', class: 'avatar project-avatar s40') .project-details %h3.prepend-top-0.append-bottom-0 = link_to project_path(project), class: dom_class(project) do diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb new file mode 100644 index 00000000000..2f02235b0ac --- /dev/null +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -0,0 +1,31 @@ +class GitlabUsagePingWorker + LEASE_TIMEOUT = 86400 + + include Sidekiq::Worker + include CronjobQueue + include HTTParty + + def perform + return unless current_application_settings.usage_ping_enabled + + # Multiple Sidekiq workers could run this. We should only do this at most once a day. + return unless try_obtain_lease + + begin + HTTParty.post(url, + body: Gitlab::UsageData.to_json(force_refresh: true), + headers: { 'Content-type' => 'application/json' } + ) + rescue HTTParty::Error => e + Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" + end + end + + def try_obtain_lease + Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain + end + + def url + 'https://version.gitlab.com/usage_data' + end +end diff --git a/app/workers/schedule_update_user_activity_worker.rb b/app/workers/schedule_update_user_activity_worker.rb new file mode 100644 index 00000000000..6c2c3e437f3 --- /dev/null +++ b/app/workers/schedule_update_user_activity_worker.rb @@ -0,0 +1,10 @@ +class ScheduleUpdateUserActivityWorker + include Sidekiq::Worker + include CronjobQueue + + def perform(batch_size = 500) + Gitlab::UserActivities.new.each_slice(batch_size) do |batch| + UpdateUserActivityWorker.perform_async(Hash[batch]) + end + end +end diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb index baf2f12eeac..55d4e7d6dab 100644 --- a/app/workers/system_hook_worker.rb +++ b/app/workers/system_hook_worker.rb @@ -2,6 +2,8 @@ class SystemHookWorker include Sidekiq::Worker include DedicatedSidekiqQueue + sidekiq_options retry: 4 + def perform(hook_id, data, hook_name) SystemHook.find(hook_id).execute(data, hook_name) end diff --git a/app/workers/update_user_activity_worker.rb b/app/workers/update_user_activity_worker.rb new file mode 100644 index 00000000000..b3c2f13aa33 --- /dev/null +++ b/app/workers/update_user_activity_worker.rb @@ -0,0 +1,26 @@ +class UpdateUserActivityWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(pairs) + pairs = cast_data(pairs) + ids = pairs.keys + conditions = 'WHEN id = ? THEN ? ' * ids.length + + User.where(id: ids). + update_all([ + "last_activity_on = CASE #{conditions} ELSE last_activity_on END", + *pairs.to_a.flatten + ]) + + Gitlab::UserActivities.new.delete(*ids) + end + + private + + def cast_data(pairs) + pairs.each_with_object({}) do |(key, value), new_pairs| + new_pairs[key.to_i] = Time.at(value.to_i).to_s(:db) + end + end +end diff --git a/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml b/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml new file mode 100644 index 00000000000..ca4a8889454 --- /dev/null +++ b/changelogs/unreleased/29734-prometheus-monitoring-page-displays-button-to-control-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Remove pipeline controls for last deployment from Environment monitoring page +merge_request: 10769 +author: diff --git a/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml b/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml new file mode 100644 index 00000000000..c1640777e12 --- /dev/null +++ b/changelogs/unreleased/29977-style-comments-and-system-notes-real-time-updates.yml @@ -0,0 +1,4 @@ +--- +title: Added quick-update (fade-in) animation to newly rendered notes +merge_request: 10623 +author: diff --git a/changelogs/unreleased/30484-profile-dropdown-account-name.yml b/changelogs/unreleased/30484-profile-dropdown-account-name.yml new file mode 100644 index 00000000000..71aa1ce139b --- /dev/null +++ b/changelogs/unreleased/30484-profile-dropdown-account-name.yml @@ -0,0 +1,4 @@ +--- +title: Added profile name to user dropdown +merge_request: +author: diff --git a/changelogs/unreleased/add-email-receiver-metrics.yml b/changelogs/unreleased/add-email-receiver-metrics.yml new file mode 100644 index 00000000000..53155651080 --- /dev/null +++ b/changelogs/unreleased/add-email-receiver-metrics.yml @@ -0,0 +1,4 @@ +--- +title: Add metrics events for incoming emails +merge_request: +author: diff --git a/changelogs/unreleased/dz-refactor-create-members.yml b/changelogs/unreleased/dz-refactor-create-members.yml new file mode 100644 index 00000000000..8cff21eabb1 --- /dev/null +++ b/changelogs/unreleased/dz-refactor-create-members.yml @@ -0,0 +1,4 @@ +--- +title: Refactor code that creates project/group members +merge_request: 10735 +author: diff --git a/changelogs/unreleased/fix-29125.yml b/changelogs/unreleased/fix-29125.yml new file mode 100644 index 00000000000..00b5e8c0a2a --- /dev/null +++ b/changelogs/unreleased/fix-29125.yml @@ -0,0 +1,4 @@ +--- +title: Display custom hook error messages when automatic merge is enabled +merge_request: +author: diff --git a/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml b/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml new file mode 100644 index 00000000000..e684a1f6684 --- /dev/null +++ b/changelogs/unreleased/fix-link-prometheus-opening-outside-gitlab.yml @@ -0,0 +1,4 @@ +--- +title: Removed target blank from the metrics action inside the environments list +merge_request: 10726 +author: diff --git a/changelogs/unreleased/group-milestone-date-fields-fix.yml b/changelogs/unreleased/group-milestone-date-fields-fix.yml new file mode 100644 index 00000000000..3cf3d3fa5ed --- /dev/null +++ b/changelogs/unreleased/group-milestone-date-fields-fix.yml @@ -0,0 +1,4 @@ +--- +title: Fixed group milestone date dropdowns not opening +merge_request: +author: diff --git a/changelogs/unreleased/hook_retries.yml b/changelogs/unreleased/hook_retries.yml new file mode 100644 index 00000000000..ee30399edbb --- /dev/null +++ b/changelogs/unreleased/hook_retries.yml @@ -0,0 +1,4 @@ +--- +title: Add retry to system hook worker +merge_request: 10801 +author: diff --git a/changelogs/unreleased/milestone-not-showing-correctly-title.yml b/changelogs/unreleased/milestone-not-showing-correctly-title.yml new file mode 100644 index 00000000000..7c21094d737 --- /dev/null +++ b/changelogs/unreleased/milestone-not-showing-correctly-title.yml @@ -0,0 +1,4 @@ +--- +title: Removed the milestone references from the milestone views +merge_request: +author: diff --git a/changelogs/unreleased/plantuml-filter-after-highlight.yml b/changelogs/unreleased/plantuml-filter-after-highlight.yml new file mode 100644 index 00000000000..f438bfd2bf7 --- /dev/null +++ b/changelogs/unreleased/plantuml-filter-after-highlight.yml @@ -0,0 +1,4 @@ +--- +title: Fix PlantUML integration in GFM +merge_request: 10651 +author: diff --git a/changelogs/unreleased/query-users-by-extern-uid.yml b/changelogs/unreleased/query-users-by-extern-uid.yml new file mode 100644 index 00000000000..39d1cf8d3f3 --- /dev/null +++ b/changelogs/unreleased/query-users-by-extern-uid.yml @@ -0,0 +1,4 @@ +--- +title: Implement search by extern_uid in Users API +merge_request: 10509 +author: Robin Bobbitt diff --git a/changelogs/unreleased/right-sidebar-closed-default-mobile.yml b/changelogs/unreleased/right-sidebar-closed-default-mobile.yml new file mode 100644 index 00000000000..cf0ec418f0e --- /dev/null +++ b/changelogs/unreleased/right-sidebar-closed-default-mobile.yml @@ -0,0 +1,4 @@ +--- +title: Set the issuable sidebar to remain closed for mobile devices +merge_request: +author: diff --git a/changelogs/unreleased/sh-add-index-to-system-note-metadata.yml b/changelogs/unreleased/sh-add-index-to-system-note-metadata.yml new file mode 100644 index 00000000000..6b226c53f30 --- /dev/null +++ b/changelogs/unreleased/sh-add-index-to-system-note-metadata.yml @@ -0,0 +1,4 @@ +--- +title: Add unique index for notes_id to system note metadata table +merge_request: +author: diff --git a/changelogs/unreleased/sh-issue-29247-fix.yml b/changelogs/unreleased/sh-issue-29247-fix.yml new file mode 100644 index 00000000000..b530e5da55b --- /dev/null +++ b/changelogs/unreleased/sh-issue-29247-fix.yml @@ -0,0 +1,5 @@ +--- +title: Don't delete a branch involved in an open merge request in "Delete all merged + branches" service +merge_request: +author: diff --git a/changelogs/unreleased/uassign_on_member_removing.yml b/changelogs/unreleased/uassign_on_member_removing.yml new file mode 100644 index 00000000000..cd60bdf5b3d --- /dev/null +++ b/changelogs/unreleased/uassign_on_member_removing.yml @@ -0,0 +1,4 @@ +--- +title: Unassign all Issues and Merge Requests when member leaves a team +merge_request: +author: diff --git a/changelogs/unreleased/usage-ping-port.yml b/changelogs/unreleased/usage-ping-port.yml new file mode 100644 index 00000000000..4f135100fce --- /dev/null +++ b/changelogs/unreleased/usage-ping-port.yml @@ -0,0 +1,4 @@ +--- +title: Add usage ping to CE +merge_request: +author: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 4c9d829aa9f..87bf48a3dcd 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -110,6 +110,14 @@ class Settings < Settingslogic URI.parse(url_without_path).host end + + # Random cron time every Sunday to load balance usage pings + def cron_random_weekly_time + hour = rand(24) + minute = rand(60) + + "#{minute} #{hour} * * 0" + end end end @@ -204,8 +212,8 @@ Settings.gitlab['email_from'] ||= ENV['GITLAB_EMAIL_FROM'] || "gitlab@#{Settings Settings.gitlab['email_display_name'] ||= ENV['GITLAB_EMAIL_DISPLAY_NAME'] || 'GitLab' Settings.gitlab['email_reply_to'] ||= ENV['GITLAB_EMAIL_REPLY_TO'] || "noreply@#{Settings.gitlab.host}" Settings.gitlab['email_subject_suffix'] ||= ENV['GITLAB_EMAIL_SUBJECT_SUFFIX'] || "" -Settings.gitlab['base_url'] ||= Settings.send(:build_base_gitlab_url) -Settings.gitlab['url'] ||= Settings.send(:build_gitlab_url) +Settings.gitlab['base_url'] ||= Settings.__send__(:build_base_gitlab_url) +Settings.gitlab['url'] ||= Settings.__send__(:build_gitlab_url) Settings.gitlab['user'] ||= 'git' Settings.gitlab['user_home'] ||= begin Etc.getpwnam(Settings.gitlab['user']).dir @@ -215,7 +223,7 @@ end Settings.gitlab['time_zone'] ||= nil Settings.gitlab['signup_enabled'] ||= true if Settings.gitlab['signup_enabled'].nil? Settings.gitlab['signin_enabled'] ||= true if Settings.gitlab['signin_enabled'].nil? -Settings.gitlab['restricted_visibility_levels'] = Settings.send(:verify_constant_array, Gitlab::VisibilityLevel, Settings.gitlab['restricted_visibility_levels'], []) +Settings.gitlab['restricted_visibility_levels'] = Settings.__send__(:verify_constant_array, Gitlab::VisibilityLevel, Settings.gitlab['restricted_visibility_levels'], []) Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username_changing_enabled'].nil? Settings.gitlab['issue_closing_pattern'] = '((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)' if Settings.gitlab['issue_closing_pattern'].nil? Settings.gitlab['default_projects_features'] ||= {} @@ -228,7 +236,7 @@ Settings.gitlab.default_projects_features['wiki'] = true if Settin Settings.gitlab.default_projects_features['snippets'] = true if Settings.gitlab.default_projects_features['snippets'].nil? Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil? Settings.gitlab.default_projects_features['container_registry'] = true if Settings.gitlab.default_projects_features['container_registry'].nil? -Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE) +Settings.gitlab.default_projects_features['visibility_level'] = Settings.__send__(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE) Settings.gitlab['domain_whitelist'] ||= [] Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab google_code fogbugz git gitlab_project gitea] Settings.gitlab['trusted_proxies'] ||= [] @@ -242,7 +250,7 @@ Settings.gitlab_ci['shared_runners_enabled'] = true if Settings.gitlab_ci['share Settings.gitlab_ci['all_broken_builds'] = true if Settings.gitlab_ci['all_broken_builds'].nil? Settings.gitlab_ci['add_pusher'] = false if Settings.gitlab_ci['add_pusher'].nil? Settings.gitlab_ci['builds_path'] = Settings.absolute(Settings.gitlab_ci['builds_path'] || "builds/") -Settings.gitlab_ci['url'] ||= Settings.send(:build_gitlab_ci_url) +Settings.gitlab_ci['url'] ||= Settings.__send__(:build_gitlab_ci_url) # # Reply by email @@ -281,7 +289,7 @@ Settings.pages['https'] = false if Settings.pages['https'].nil? Settings.pages['host'] ||= "example.com" Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" -Settings.pages['url'] ||= Settings.send(:build_pages_url) +Settings.pages['url'] ||= Settings.__send__(:build_pages_url) Settings.pages['external_http'] ||= false unless Settings.pages['external_http'].present? Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present? @@ -355,6 +363,14 @@ Settings.cron_jobs['remove_unreferenced_lfs_objects_worker']['job_class'] = 'Rem Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *' Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker' +Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time) +Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker' + +# Every day at 00:30 +Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *' +Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker' # # GitLab Shell @@ -369,7 +385,7 @@ Settings.gitlab_shell['ssh_host'] ||= Settings.gitlab.ssh_host Settings.gitlab_shell['ssh_port'] ||= 22 Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user -Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.send(:build_gitlab_shell_ssh_path_prefix) +Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix) # # Repositories diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 486ce3c5c87..52ba10604d4 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -91,6 +91,7 @@ namespace :admin do resource :application_settings, only: [:show, :update] do resources :services, only: [:index, :edit, :update] + get :usage_data put :reset_runners_token put :reset_health_check_token put :clear_repository_check_states @@ -105,6 +106,8 @@ namespace :admin do end end + resources :cohorts, only: :index + resources :builds, only: :index do collection do post :cancel_all diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 9d2066a6490..bf8964d7f68 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -53,3 +53,4 @@ - [default, 1] - [pages, 1] - [system_hook_push, 1] + - [update_user_activity, 1] diff --git a/db/migrate/20160713222618_add_usage_ping_to_application_settings.rb b/db/migrate/20160713222618_add_usage_ping_to_application_settings.rb new file mode 100644 index 00000000000..a7f76cc626e --- /dev/null +++ b/db/migrate/20160713222618_add_usage_ping_to_application_settings.rb @@ -0,0 +1,9 @@ +class AddUsagePingToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :usage_ping_enabled, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20161007073613_create_user_activities.rb b/db/migrate/20161007073613_create_user_activities.rb new file mode 100644 index 00000000000..1d694e777a1 --- /dev/null +++ b/db/migrate/20161007073613_create_user_activities.rb @@ -0,0 +1,7 @@ +class CreateUserActivities < ActiveRecord::Migration + DOWNTIME = false + + # This migration is a no-op. It just exists to match EE. + def change + end +end diff --git a/db/migrate/20170307125949_add_last_activity_on_to_users.rb b/db/migrate/20170307125949_add_last_activity_on_to_users.rb new file mode 100644 index 00000000000..0100836b473 --- /dev/null +++ b/db/migrate/20170307125949_add_last_activity_on_to_users.rb @@ -0,0 +1,9 @@ +class AddLastActivityOnToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :users, :last_activity_on, :date + end +end diff --git a/db/migrate/20170328010804_add_uuid_to_application_settings.rb b/db/migrate/20170328010804_add_uuid_to_application_settings.rb new file mode 100644 index 00000000000..5dfcc751c7b --- /dev/null +++ b/db/migrate/20170328010804_add_uuid_to_application_settings.rb @@ -0,0 +1,16 @@ +class AddUuidToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column :application_settings, :uuid, :string + execute("UPDATE application_settings SET uuid = #{quote(SecureRandom.uuid)}") + end + + def down + remove_column :application_settings, :uuid + end +end diff --git a/db/migrate/20170419001229_add_index_to_system_note_metadata.rb b/db/migrate/20170419001229_add_index_to_system_note_metadata.rb new file mode 100644 index 00000000000..c68fd920fff --- /dev/null +++ b/db/migrate/20170419001229_add_index_to_system_note_metadata.rb @@ -0,0 +1,17 @@ +class AddIndexToSystemNoteMetadata < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + # MySQL automatically creates an index on a foreign-key constraint; PostgreSQL does not + add_concurrent_index :system_note_metadata, :note_id, unique: true if Gitlab::Database.postgresql? + end + + def down + remove_concurrent_index :system_note_metadata, :note_id, unique: true if Gitlab::Database.postgresql? + end +end diff --git a/db/post_migrate/20161128170531_drop_user_activities_table.rb b/db/post_migrate/20161128170531_drop_user_activities_table.rb new file mode 100644 index 00000000000..00bc0c73015 --- /dev/null +++ b/db/post_migrate/20161128170531_drop_user_activities_table.rb @@ -0,0 +1,9 @@ +class DropUserActivitiesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + # This migration is a no-op. It just exists to match EE. + def change + end +end diff --git a/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb b/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb new file mode 100644 index 00000000000..9ad36482c8a --- /dev/null +++ b/db/post_migrate/20170324160416_migrate_user_activities_to_users_last_activity_on.rb @@ -0,0 +1,87 @@ +class MigrateUserActivitiesToUsersLastActivityOn < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + USER_ACTIVITY_SET_KEY = 'user/activities'.freeze + ACTIVITIES_PER_PAGE = 100 + TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED = Time.utc(2016, 12, 1) + + def up + return if activities_count(TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED, Time.now).zero? + + day = Time.at(activities(TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED, Time.now).first.second) + + transaction do + while day <= Time.now.utc.tomorrow + persist_last_activity_on(day: day) + day = day.tomorrow + end + end + end + + def down + # This ensures we don't lock all users for the duration of the migration. + update_column_in_batches(:users, :last_activity_on, nil) do |table, query| + query.where(table[:last_activity_on].not_eq(nil)) + end + end + + private + + def persist_last_activity_on(day:, page: 1) + activities_count = activities_count(day.at_beginning_of_day, day.at_end_of_day) + + return if activities_count.zero? + + activities = activities(day.at_beginning_of_day, day.at_end_of_day, page: page) + + update_sql = + Arel::UpdateManager.new(ActiveRecord::Base). + table(users_table). + set(users_table[:last_activity_on] => day.to_date). + where(users_table[:username].in(activities.map(&:first))). + to_sql + + connection.exec_update(update_sql, self.class.name, []) + + unless last_page?(page, activities_count) + persist_last_activity_on(day: day, page: page + 1) + end + end + + def users_table + @users_table ||= Arel::Table.new(:users) + end + + def activities(from, to, page: 1) + Gitlab::Redis.with do |redis| + redis.zrangebyscore(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i, + with_scores: true, + limit: limit(page)) + end + end + + def activities_count(from, to) + Gitlab::Redis.with do |redis| + redis.zcount(USER_ACTIVITY_SET_KEY, from.to_i, to.to_i) + end + end + + def limit(page) + [offset(page), ACTIVITIES_PER_PAGE] + end + + def total_pages(count) + (count.to_f / ACTIVITIES_PER_PAGE).ceil + end + + def last_page?(page, count) + page >= total_pages(count) + end + + def offset(page) + (page - 1) * ACTIVITIES_PER_PAGE + end +end diff --git a/db/schema.rb b/db/schema.rb index 26ec6367bd0..f89e9adb7d6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170418103908) do +ActiveRecord::Schema.define(version: 20170419001229) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -116,6 +116,8 @@ ActiveRecord::Schema.define(version: 20170418103908) do t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false + t.boolean "usage_ping_enabled", default: true, null: false + t.string "uuid" end create_table "audit_events", force: :cascade do |t| @@ -1140,6 +1142,8 @@ ActiveRecord::Schema.define(version: 20170418103908) do t.datetime "updated_at", null: false end + add_index "system_note_metadata", ["note_id"], name: "index_system_note_metadata_on_note_id", unique: true, using: :btree + create_table "taggings", force: :cascade do |t| t.integer "tag_id" t.integer "taggable_id" @@ -1301,6 +1305,7 @@ ActiveRecord::Schema.define(version: 20170418103908) do t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" + t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false diff --git a/doc/README.md b/doc/README.md index 7703c64e152..9e6a5b4ed44 100644 --- a/doc/README.md +++ b/doc/README.md @@ -69,6 +69,7 @@ All technical content published by GitLab lives in the documentation, including: - [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs. - [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed. - [Update](update/README.md) Update guides to upgrade your installation. +- [User cohorts](user/admin_area/user_cohorts.md) View user activity over time. - [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab. - [Welcome message](customization/welcome_message.md) Add a custom welcome message to the sign-in page. diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index 6515b1a264a..5c856835039 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -1,6 +1,6 @@ # PlantUML & GitLab -> [Introduced][ce-7810] in GitLab 8.16. +> [Introduced][ce-8537] in GitLab 8.16. When [PlantUML](http://plantuml.com) integration is enabled and configured in GitLab we are able to create simple diagrams in AsciiDoc and Markdown documents @@ -93,3 +93,5 @@ Some parameters can be added to the AsciiDoc block definition: - *height*: Height attribute added to the img tag. Markdown does not support any parameters and will always use PNG format. + +[ce-8537]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8537
\ No newline at end of file diff --git a/doc/api/users.md b/doc/api/users.md index 2ada4d09c84..ff5bffa178f 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -72,6 +72,7 @@ GET /users "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", + "last_activity_on": "2012-05-23", "color_scheme_id": 2, "projects_limit": 100, "current_sign_in_at": "2012-06-02T06:36:55Z", @@ -104,6 +105,7 @@ GET /users "organization": "", "last_sign_in_at": null, "confirmed_at": "2012-05-30T16:53:06.148Z", + "last_activity_on": "2012-05-23", "color_scheme_id": 3, "projects_limit": 100, "current_sign_in_at": "2014-03-19T17:54:13Z", @@ -130,6 +132,18 @@ For example: GET /users?username=jack_smith ``` +You can also lookup users by external UID and provider: + +``` +GET /users?extern_uid=:extern_uid&provider=:provider +``` + +For example: + +``` +GET /users?extern_uid=1234567&provider=github +``` + You can search for users who are external with: `/users?external=true` ## Single user @@ -196,6 +210,7 @@ Parameters: "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", + "last_activity_on": "2012-05-23", "color_scheme_id": 2, "projects_limit": 100, "current_sign_in_at": "2012-06-02T06:36:55Z", @@ -320,6 +335,7 @@ GET /user "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", + "last_activity_on": "2012-05-23", "color_scheme_id": 2, "projects_limit": 100, "current_sign_in_at": "2012-06-02T06:36:55Z", @@ -365,6 +381,7 @@ GET /user "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", + "last_activity_on": "2012-05-23", "color_scheme_id": 2, "projects_limit": 100, "current_sign_in_at": "2012-06-02T06:36:55Z", @@ -986,3 +1003,55 @@ Parameters: | --------- | ---- | -------- | ----------- | | `user_id` | integer | yes | The ID of the user | | `impersonation_token_id` | integer | yes | The ID of the impersonation token | + +### Get user activities (admin only) + +>**Note:** This API endpoint is only available on 8.15 (EE) and 9.1 (CE) and above. + +Get the last activity date for all users, sorted from oldest to newest. + +The activities that update the timestamp are: + + - Git HTTP/SSH activities (such as clone, push) + - User logging in into GitLab + +By default, it shows the activity for all users in the last 6 months, but this can be +amended by using the `from` parameter. + +``` +GET /user/activities +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `from` | string | no | Date string in the format YEAR-MONTH-DAY, e.g. `2016-03-11`. Defaults to 6 months ago. | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/user/activities +``` + +Example response: + +```json +[ + { + "username": "user1", + "last_activity_on": "2015-12-14", + "last_activity_at": "2015-12-14" + }, + { + "username": "user2", + "last_activity_on": "2015-12-15", + "last_activity_at": "2015-12-15" + }, + { + "username": "user3", + "last_activity_on": "2015-12-16", + "last_activity_at": "2015-12-16" + } +] +``` + +Please note that `last_activity_at` is deprecated, please use `last_activity_on`. diff --git a/doc/articles/index.md b/doc/articles/index.md new file mode 100644 index 00000000000..67eab36bf2c --- /dev/null +++ b/doc/articles/index.md @@ -0,0 +1,16 @@ +# Technical Articles + +[Technical Articles](../development/writing_documentation.md#technical-articles) are +topic-related documentation, written with an user-friendly approach and language, aiming +to provide the community with guidance on specific processes to achieve certain objectives. + +They are written by members of the GitLab Team and by +[Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). + +## GitLab Pages + +- **GitLab Pages from A to Z** + - [Part 1: Static sites and GitLab Pages domains](../user/project/pages/getting_started_part_one.md) + - [Part 2: Quick start guide - Setting up GitLab Pages](../user/project/pages/getting_started_part_two.md) + - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](../user/project/pages/getting_started_part_three.md) + - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](../user/project/pages/getting_started_part_four.md) diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index bb78a0de0c5..1e81905c081 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -29,7 +29,8 @@ The table below shows what kind of documentation goes where. | `doc/legal/` | Legal documents about contributing to GitLab. | | `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | | `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | -| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`); Technical Articles: user guides, admin guides, technical overviews, tutorials (`doc/topics/topic-name/`). | +| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) | +| `doc/articles/` | [Technical Articles](writing_documentation.md#technical-articles): user guides, admin guides, technical overviews, tutorials (`doc/articles/article-title/index.md`). | --- @@ -61,8 +62,8 @@ The table below shows what kind of documentation goes where. located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. 1. The `doc/topics/` directory holds topic-related technical content. Create `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary. - Note that `topics` holds the index page per topic, and technical articles. General - user- and admin- related documentation, should be placed accordingly. + General user- and admin- related documentation, should be placed accordingly. +1. For technical articles, place their images under `doc/articles/article-title/img/`. --- diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md new file mode 100644 index 00000000000..8f0b6b21953 --- /dev/null +++ b/doc/development/fe_guide/droplab/droplab.md @@ -0,0 +1,256 @@ +# DropLab + +A generic dropdown for all of your custom dropdown needs. + +## Usage + +DropLab can be used by simply adding a `data-dropdown-trigger` HTML attribute. +This attribute allows us to find the "trigger" _(toggle)_ for the dropdown, +whether that is a button, link or input. + +The value of the `data-dropdown-trigger` should be a CSS selector that +DropLab can use to find the trigger's dropdown list. + +You should also add the `data-dropdown` attribute to declare the dropdown list. +The value is irrelevant. + +The DropLab class has no side effects, so you must always call `.init` when +the DOM is ready. `DropLab.prototype.init` takes the same arguments as `DropLab.prototype.addHook`. +If you do not provide any arguments, it will globally query and instantiate all droplab compatible dropdowns. + +```html +<a href="#" data-dropdown-trigger="#list">Toggle</a> + +<ul id="list" data-dropdown> + <!-- ... --> +<ul> +``` +```js +const droplab = new DropLab(); +droplab.init(); +``` + +As you can see, we have a "Toggle" link, that is declared as a trigger. +It provides a selector to find the dropdown list it should control. + +### Static data + +You can add static list items. + +```html +<a href="#" data-dropdown-trigger="#list">Toggle</a> + +<ul id="list" data-dropdown> + <li>Static value 1</li> + <li>Static value 2</li> +<ul> +``` +```js +const droplab = new DropLab(); +droplab.init(); +``` + +### Explicit instantiation + +You can pass the trigger and list elements as constructor arguments to return +a non-global instance of DropLab using the `DropLab.prototype.init` method. + +```html +<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> + +<ul id="list" data-dropdown> + <!-- ... --> +<ul> +``` +```js +const trigger = document.getElementById('trigger'); +const list = document.getElementById('list'); + +const droplab = new DropLab(); +droplab.init(trigger, list); +``` + +You can also add hooks to an existing DropLab instance using `DropLab.prototype.addHook`. + +```html +<a href="#" data-dropdown-trigger="#auto-dropdown">Toggle</a> +<ul id="auto-dropdown" data-dropdown><!-- ... --><ul> + +<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> +<ul id="list" data-dropdown><!-- ... --><ul> +``` +```js +const droplab = new DropLab(); + +droplab.init(); + +const trigger = document.getElementById('trigger'); +const list = document.getElementById('list'); + +droplab.addHook(trigger, list); +``` + + +### Dynamic data + +Adding `data-dynamic` to your dropdown element will enable dynamic list rendering. + +You can template a list item using the keys of the data object provided. +Use the handlebars syntax `{{ value }}` to HTML escape the value. +Use the `<%= value %>` syntax to simply interpolate the value. +Use the `<%= value %>` syntax to evaluate the value. + +Passing an array of objects to `DropLab.prototype.addData` will render that data +for all `data-dynamic` dropdown lists tracked by that DropLab instance. + +```html +<a href="#" data-dropdown-trigger="#list">Toggle</a> + +<ul id="list" data-dropdown data-dynamic> + <li><a href="#" data-id="{{id}}">{{text}}</a></li> +</ul> +``` +```js +const droplab = new DropLab(); + +droplab.init().addData([{ + id: 0, + text: 'Jacob', +}, { + id: 1, + text: 'Jeff', +}]); +``` + +Alternatively, you can specify a specific dropdown to add this data to but passing +the data as the second argument and and the `id` of the trigger element as the first argument. + +```html +<a href="#" data-dropdown-trigger="#list" id="trigger">Toggle</a> + +<ul id="list" data-dropdown data-dynamic> + <li><a href="#" data-id="{{id}}">{{text}}</a></li> +</ul> +``` +```js +const droplab = new DropLab(); + +droplab.init().addData('trigger', [{ + id: 0, + text: 'Jacob', +}, { + id: 1, + text: 'Jeff', +}]); +``` + +This allows you to mix static and dynamic content with ease, even with one trigger. + +Note the use of scoping regarding the `data-dropdown` attribute to capture both +dropdown lists, one of which is dynamic. + +```html +<input id="trigger" data-dropdown-trigger="#list"> +<div id="list" data-dropdown> + <ul> + <li><a href="#">Static item 1</a></li> + <li><a href="#">Static item 2</a></li> + </ul> + <ul data-dynamic> + <li><a href="#" data-id="{{id}}">{{text}}</a></li> + </ul> +</div> +``` +```js +const droplab = new DropLab(); + +droplab.init().addData('trigger', [{ + id: 0, + text: 'Jacob', +}, { + id: 1, + text: 'Jeff', +}]); +``` + +## Internal selectors + +DropLab adds some CSS classes to help lower the barrier to integration. + +For example, + +* The `droplab-item-selected` css class is added to items that have been selected +either by a mouse click or by enter key selection. +* The `droplab-item-active` css class is added to items that have been selected +using arrow key navigation. + +## Internal events + +DropLab uses some custom events to help lower the barrier to integration. + +For example, + +* The `click.dl` event is fired when an `li` list item has been clicked. It is also +fired when a list item has been selected with the keyboard. It is also fired when a +`HookButton` button is clicked (a registered `button` tag or `a` tag trigger). +* The `input.dl` event is fired when a `HookInput` (a registered `input` tag trigger) triggers an `input` event. +* The `mousedown.dl` event is fired when a `HookInput` triggers a `mousedown` event. +* The `keyup.dl` event is fired when a `HookInput` triggers a `keyup` event. +* The `keydown.dl` event is fired when a `HookInput` triggers a `keydown` event. + +These custom events add a `detail` object to the vanilla `Event` object that provides some potentially useful data. + +## Plugins + +Plugins are objects that are registered to be executed when a hook is added (when a droplab trigger and dropdown are instantiated). + +If no modules API is detected, the library will fall back as it does with `window.DropLab` and will add `window.DropLab.plugins.PluginName`. + +### Usage + +To use plugins, you can pass them in an array as the third argument of `DropLab.prototype.init` or `DropLab.prototype.addHook`. +Some plugins require configuration values, the config object can be passed as the fourth argument. + +```html +<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> +<ul id="list" data-dropdown><!-- ... --><ul> +``` +```js +const droplab = new DropLab(); + +const trigger = document.getElementById('trigger'); +const list = document.getElementById('list'); + +droplab.init(trigger, list, [droplabAjax], { + droplabAjax: { + endpoint: '/some-endpoint', + method: 'setData', + }, +}); +``` + +### Documentation + +* [Ajax plugin](plugins/ajax.md) +* [Filter plugin](plugins/filter.md) +* [InputSetter plugin](plugins/input_setter.md) + +### Development + +When plugins are initialised for a droplab trigger+dropdown, DropLab will +call the plugins `init` function, so this must be implemented in the plugin. + +```js +class MyPlugin { + static init() { + this.someProp = 'someProp'; + this.someMethod(); + } + + static someMethod() { + this.otherProp = 'otherProp'; + } +} + +export default MyPlugin; +``` diff --git a/doc/development/fe_guide/droplab/plugins/ajax.md b/doc/development/fe_guide/droplab/plugins/ajax.md new file mode 100644 index 00000000000..9c7e56fa448 --- /dev/null +++ b/doc/development/fe_guide/droplab/plugins/ajax.md @@ -0,0 +1,37 @@ +# Ajax + +`Ajax` is a droplab plugin that allows for retrieving and rendering list data from a server. + +## Usage + +Add the `Ajax` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call. + +`Ajax` requires 2 config values, the `endpoint` and `method`. + +* `endpoint` should be a URL to the request endpoint. +* `method` should be `setData` or `addData`. +* `setData` completely replaces the dropdown with the response data. +* `addData` appends the response data to the current dropdown list. + +```html +<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a> +<ul id="list" data-dropdown><!-- ... --><ul> +``` +```js + const droplab = new DropLab(); + + const trigger = document.getElementById('trigger'); + const list = document.getElementById('list'); + + droplab.addHook(trigger, list, [Ajax], { + Ajax: { + endpoint: '/some-endpoint', + method: 'setData', + }, + }); +``` + +Optionally you can set `loadingTemplate` to a HTML string. This HTML string will +replace the dropdown list whilst the request is pending. + +Additionally, you can set `onError` to a function to catch any XHR errors. diff --git a/doc/development/fe_guide/droplab/plugins/filter.md b/doc/development/fe_guide/droplab/plugins/filter.md new file mode 100644 index 00000000000..0853ea4d320 --- /dev/null +++ b/doc/development/fe_guide/droplab/plugins/filter.md @@ -0,0 +1,45 @@ +# Filter + +`Filter` is a plugin that allows for filtering data that has been added +to the dropdown using a simple fuzzy string search of an input value. + +## Usage + +Add the `Filter` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call. + +* `Filter` requires a config value for `template`. +* `template` should be the key of the objects within your data array that you want to compare +to the user input string, for filtering. + +```html +<input href="#" id="trigger" data-dropdown-trigger="#list"> +<ul id="list" data-dropdown data-dynamic> + <li><a href="#" data-id="{{id}}">{{text}}</a></li> +<ul> +``` +```js + const droplab = new DropLab(); + + const trigger = document.getElementById('trigger'); + const list = document.getElementById('list'); + + droplab.init(trigger, list, [Filter], { + Filter: { + template: 'text', + }, + }); + + droplab.addData('trigger', [{ + id: 0, + text: 'Jacob', + }, { + id: 1, + text: 'Jeff', + }]); +``` + +Above, the input string will be compared against the `test` key of the passed data objects. + +Optionally you can set `filterFunction` to a function. This function will be used instead +of `Filter`'s built in string search. `filterFunction` is passed 2 arguments, the first +is one of the data objects, the second is the current input value. diff --git a/doc/development/fe_guide/droplab/plugins/input_setter.md b/doc/development/fe_guide/droplab/plugins/input_setter.md new file mode 100644 index 00000000000..a549603c20d --- /dev/null +++ b/doc/development/fe_guide/droplab/plugins/input_setter.md @@ -0,0 +1,60 @@ +# InputSetter + +`InputSetter` is a plugin that allows for udating DOM out of the scope of droplab when a list item is clicked. + +## Usage + +Add the `InputSetter` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call. + +* `InputSetter` requires a config value for `input` and `valueAttribute`. +* `input` should be the DOM element that you want to manipulate. +* `valueAttribute` should be a string that is the name of an attribute on your list items that is used to get the value +to update the `input` element with. + +You can also set the `InputSetter` config to an array of objects, which will allow you to update multiple elements. + + +```html +<input id="input" value=""> +<div id="div" data-selected-id=""></div> + +<input href="#" id="trigger" data-dropdown-trigger="#list"> +<ul id="list" data-dropdown data-dynamic> + <li><a href="#" data-id="{{id}}">{{text}}</a></li> +<ul> +``` +```js + const droplab = new DropLab(); + + const trigger = document.getElementById('trigger'); + const list = document.getElementById('list'); + + const input = document.getElementById('input'); + const div = document.getElementById('div'); + + droplab.init(trigger, list, [InputSetter], { + InputSetter: [{ + input: input, + valueAttribute: 'data-id', + } { + input: div, + valueAttribute: 'data-id', + inputAttribute: 'data-selected-id', + }], + }); + + droplab.addData('trigger', [{ + id: 0, + text: 'Jacob', + }, { + id: 1, + text: 'Jeff', + }]); +``` + +Above, if the second list item was clicked, it would update the `#input` element +to have a `value` of `1`, it would also update the `#div` element's `data-selected-id` to `1`. + +Optionally you can set `inputAttribute` to a string that is the name of an attribute on your `input` element that you want to update. +If you do not provide an `inputAttribute`, `InputSetter` will update the `value` of the `input` element if it is an `INPUT` element, +or the `textContent` of the `input` element if it is not an `INPUT` element. diff --git a/doc/development/fe_guide/img/boards_diagram.png b/doc/development/fe_guide/img/boards_diagram.png Binary files differnew file mode 100644 index 00000000000..7a2cf972fd0 --- /dev/null +++ b/doc/development/fe_guide/img/boards_diagram.png diff --git a/doc/development/fe_guide/img/vue_arch.png b/doc/development/fe_guide/img/vue_arch.png Binary files differnew file mode 100644 index 00000000000..a67706c7c1e --- /dev/null +++ b/doc/development/fe_guide/img/vue_arch.png diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index f963bffde37..e2a198f637f 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -27,6 +27,59 @@ For our currently-supported browsers, see our [requirements][requirements]. --- +## Development Process + +When you are assigned an issue please follow the next steps: + +### Divide a big feature into small Merge Requests +1. Big Merge Request are painful to review. In order to make this process easier we +must break a big feature into smaller ones and create a Merge Request for each step. +1. First step is to create a branch from `master`, let's call it `new-feature`. This branch +will be the recipient of all the smaller Merge Requests. Only this one will be merged to master. +1. Don't do any work on this one, let's keep it synced with master. +1. Create a new branch from `new-feature`, let's call it `new-feature-step-1`. We advise you +to clearly identify which step the branch represents. +1. Do the first part of the modifications in this branch. The target branch of this Merge Request +should be `new-feature`. +1. Once `new-feature-step-1` gets merged into `new-feature` we can continue our work. Create a new +branch from `new-feature`, let's call it `new-feature-step-2` and repeat the process done before. + +```shell +* master +|\ +| * new-feature +| |\ +| | * new-feature-step-1 +| |\ +| | * new-feature-step-2 +| |\ +| | * new-feature-step-3 +``` + +**Tips** +- Make sure `new-feature` branch is always synced with `master`: merge master frequently. +- Do the same for the feature branch you have opened. This can be accomplished by merging `master` into `new-feature` and `new-feature` into `new-feature-step-*` +- Avoid rewriting history. + +### Share your work early +1. Before writing code guarantee your vision of the architecture is aligned with +GitLab's architecture. +1. Add a diagram to the issue and ask a Frontend Architecture about it. + + ![Diagram of Issue Boards Architecture](img/boards_diagram.png) + +1. Don't take more than one week between starting work on a feature and +sharing a Merge Request with a reviewer or a maintainer. + +### Vue features +1. Follow the steps in [Vue.js Best Practices](vue.md) +1. Follow the style guide. +1. Only a handful of people are allowed to merge Vue related features. +Reach out to @jschatz, @iamphill, @fatihacet or @filipa early in this process. + + +--- + ## [Architecture](architecture.md) How we go about making fundamental design decisions in GitLab's frontend team or make changes to our frontend development guidelines. @@ -90,3 +143,13 @@ Our accessibility standards and resources. [scss-lint]: https://github.com/brigade/scss-lint [install]: ../../install/installation.md#4-node [requirements]: ../../install/requirements.md#supported-web-browsers + +--- + +## [DropLab](droplab/droplab.md) +Our internal `DropLab` dropdown library. + +* [DropLab](droplab/droplab.md) +* [Ajax plugin](droplab/plugins/ajax.md) +* [Filter plugin](droplab/plugins/filter.md) +* [InputSetter plugin](droplab/plugins/input_setter.md) diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index abd241c0bc8..1d2b0558948 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -58,7 +58,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. import Bar from './bar'; ``` -- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. +- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. - When declaring multiple globals, always use one `/* global [name] */` line per variable. @@ -71,6 +71,16 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. /* global Cookies */ /* global jQuery */ ``` + +- Use up to 3 parameters for a function or class. If you need more accept an Object instead. + + ```javascript + // bad + fn(p1, p2, p3, p4) {} + + // good + fn(options) {} + ``` #### Modules, Imports, and Exports - Use ES module syntax to import modules @@ -168,6 +178,23 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. - Avoid constructors with side-effects +- Prefer `.map`, `.reduce` or `.filter` over `.forEach` +A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`, +`.reduce` or `.filter` + + ```javascript + const users = [ { name: 'Foo' }, { name: 'Bar' } ]; + + // bad + users.forEach((user, index) => { + user.id = index; + }); + + // good + const usersWithId = users.map((user, index) => { + return Object.assign({}, user, { id: index }); + }); + ``` #### Parse Strings into Numbers - `parseInt()` is preferable over `Number()` or `+` @@ -183,6 +210,19 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. parseInt('10', 10); ``` +#### CSS classes used for JavaScript +- If the class is being used in Javascript it needs to be prepend with `js-` + ```html + // bad + <button class="add-user"> + Add User + </button> + + // good + <button class="js-add-user"> + Add User + </button> + ``` ### Vue.js @@ -200,6 +240,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. #### Naming - **Extensions**: Use `.vue` extension for Vue components. - **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: + ```javascript // bad import cardBoard from 'cardBoard'; @@ -217,6 +258,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. cardBoard: CardBoard }; ``` + - **Props Naming:** - Avoid using DOM component prop names. - Use kebab-case instead of camelCase to provide props in templates. @@ -243,12 +285,18 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. <component v-if="bar" param="baz" /> + <button class="btn">Click me</button> + // good <component v-if="bar" param="baz" /> + <button class="btn"> + Click me + </button> + // if props fit in one line then keep it on the same line <component bar="bar" /> ``` diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index a4631fd0073..66afbf4db4d 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -26,6 +26,10 @@ browser and you will not have access to certain APIs, such as [`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification), which will have to be stubbed. +### Writing tests +### Vue.js unit tests +See this [section][vue-test]. + ### Running frontend tests `rake karma` runs the frontend-only (JavaScript) tests. @@ -134,3 +138,4 @@ Scenario: Developer can approve merge request [jasmine-focus]: https://jasmine.github.io/2.5/focused_specs.html [jasmine-jquery]: https://github.com/velesin/jasmine-jquery [karma]: http://karma-runner.github.io/ +[vue-test]:https://docs.gitlab.com/ce/development/fe_guide/vue.html#testing-vue-components diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 3e3406e7d6a..45c8300d9de 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -19,13 +19,31 @@ We don't want to refactor all GitLab frontend code into Vue.js, here are some gu when not to use Vue.js: - Adding or changing static information; -- Features that highly depend on jQuery will be hard to work with Vue.js +- Features that highly depend on jQuery will be hard to work with Vue.js; +- Features without reactive data; As always, the Frontend Architectural Experts are available to help with any Vue or JavaScript questions. -## How to build a new feature with Vue.js +## Vue architecture -**Components, Stores and Services** +All new features built with Vue.js must follow a [Flux architecture][flux]. +The main goal we are trying to achieve is to have only one data flow and only one data entry. +In order to achieve this goal, each Vue bundle needs a Store - where we keep all the data -, +a Service - that we use to communicate with the server - and a main Vue component. + +Think of the Main Vue Component as the entry point of your application. This is the only smart +component that should exist in each Vue feature. +This component is responsible for: +1. Calling the Service to get data from the server +1. Calling the Store to store the data received +1. Mounting all the other components + + ![Vue Architecture](img/vue_arch.png) + +You can also read about this architecture in vue docs about [state management][state-management] +and about [one way data flow][one-way-data-flow]. + +### Components, Stores and Services In some features implemented with Vue.js, like the [issue board][issue-boards] or [environments table][environments-table] @@ -46,16 +64,17 @@ _For consistency purposes, we recommend you to follow the same structure._ Let's look into each of them: -**A `*_bundle.js` file** +### A `*_bundle.js` file This is the index file of your new feature. This is where the root Vue instance of the new feature should be. -The Store and the Service should be imported and initialized in this file and provided as a prop to the main component. +The Store and the Service should be imported and initialized in this file and +provided as a prop to the main component. Don't forget to follow [these steps.][page_specific_javascript] -**A folder for Components** +### A folder for Components This folder holds all components that are specific of this new feature. If you need to use or create a component that will probably be used somewhere @@ -70,29 +89,320 @@ in one table would not be a good use of this pattern. You can read more about components in Vue.js site, [Component System][component-system] -**A folder for the Store** +### A folder for the Store The Store is a class that allows us to manage the state in a single -source of truth. +source of truth. It is not aware of the service or the components. The concept we are trying to follow is better explained by Vue documentation itself, please read this guide: [State Management][state-management] -**A folder for the Service** +### A folder for the Service + +The Service is a class used only to communicate with the server. +It does not store or manipulate any data. It is not aware of the store or the components. +We use [vue-resource][vue-resource-repo] to communicate with the server. + +### End Result + +The following example shows an application: + +```javascript +// store.js +export default class Store { + + /** + * This is where we will iniatialize the state of our data. + * Usually in a small SPA you don't need any options when starting the store. In the case you do + * need guarantee it's an Object and it's documented. + * + * @param {Object} options + */ + constructor(options) { + this.options = options; + + // Create a state object to handle all our data in the same place + this.todos = []: + } + + setTodos(todos = []) { + this.todos = todos; + } + + addTodo(todo) { + this.todos.push(todo); + } + + removeTodo(todoID) { + const state = this.todos; + + const newState = state.filter((element) => {element.id !== todoID}); + + this.todos = newState; + } +} + +// service.js +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import 'vue_shared/vue_resource_interceptor'; + +Vue.use(VueResource); + +export default class Service { + constructor(options) { + this.todos = Vue.resource(endpoint.todosEndpoint); + } + + getTodos() { + return this.todos.get(); + } + + addTodo(todo) { + return this.todos.put(todo); + } +} +// todo_component.vue +<script> +export default { + props: { + data: { + type: Object, + required: true, + }, + } +} +</script> +<template> + <div> + <h1> + Title: {{data.title}} + </h1> + <p> + {{data.text}} + </p> + </div> +</template> + +// todos_main_component.vue +<script> +import Store from 'store'; +import Service from 'service'; +import TodoComponent from 'todoComponent'; +export default { + /** + * Although most data belongs in the store, each component it's own state. + * We want to show a loading spinner while we are fetching the todos, this state belong + * in the component. + * + * We need to access the store methods through all methods of our component. + * We need to access the state of our store. + */ + data() { + const store = new Store(); + + return { + isLoading: false, + store: store, + todos: store.todos, + }; + }, + + components: { + todo: TodoComponent, + }, + + created() { + this.service = new Service('todos'); + + this.getTodos(); + }, + + methods: { + getTodos() { + this.isLoading = true; + + this.service.getTodos() + .then(response => response.json()) + .then((response) => { + this.store.setTodos(response); + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + // Show an error + }); + }, + + addTodo(todo) { + this.service.addTodo(todo) + then(response => response.json()) + .then((response) => { + this.store.addTodo(response); + }) + .catch(() => { + // Show an error + }); + } + } +} +</script> +<template> + <div class="container"> + <div v-if="isLoading"> + <i + class="fa fa-spin fa-spinner" + aria-hidden="true" /> + </div> + + <div + v-if="!isLoading" + class="js-todo-list"> + <template v-for='todo in todos'> + <todo :data="todo" /> + </template> + + <button + @click="addTodo" + class="js-add-todo"> + Add Todo + </button> + </div> + <div> +</template> + +// bundle.js +import todoComponent from 'todos_main_component.vue'; + +new Vue({ + el: '.js-todo-app', + components: { + todoComponent, + }, + render: createElement => createElement('todo-component' { + props: { + someProp: [], + } + }), +}); -The Service is used only to communicate with the server. -It does not store or manipulate any data. -We use [vue-resource][vue-resource-repo] to -communicate with the server. +``` -The [issue boards service][issue-boards-service] -is a good example of this pattern. +The [issue boards service][issue-boards-service] is a good example of this pattern. ## Style guide Please refer to the Vue section of our [style guide](style_guide_js.md#vuejs) for best practices while writing your Vue components and templates. +## Testing Vue Components + +Each Vue component has a unique output. This output is always present in the render function. + +Although we can test each method of a Vue component individually, our goal must be to test the output +of the render/template function, which represents the state at all times. + +Make use of Vue Resource Interceptors to mock data returned by the service. + +Here's how we would test the Todo App above: + +```javascript +import component from 'todos_main_component'; + +describe('Todos App', () => { + it('should render the loading state while the request is being made', () => { + const Component = Vue.extend(component); + + const vm = new Component().$mount(); + + expect(vm.$el.querySelector('i.fa-spin')).toBeDefined(); + }); + + describe('with data', () => { + // Mock the service to return data + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify([{ + title: 'This is a todo', + body: 'This is the text' + }]), { + status: 200, + })); + }; + + let vm; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + + const Component = Vue.extend(component); + + vm = new Component().$mount(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + + it('should render todos', (done) => { + setTimeout(() => { + expect(vm.$el.querySelectorAll('.js-todo-list div').length).toBe(1); + done(); + }, 0); + }); + }); + + describe('add todo', () => { + let vm; + beforeEach(() => { + const Component = Vue.extend(component); + vm = new Component().$mount(); + }); + it('should add a todos', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-add-todo').click(); + + // Add a new interceptor to mock the add Todo request + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.js-todo-list div').length).toBe(2); + }); + }, 0); + }); + }); +}); +``` + +### Stubbing API responses +[Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with +the response we need: + +```javascript + // Mock the service to return data + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify([{ + title: 'This is a todo', + body: 'This is the text' + }]), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should do something', (done) => { + setTimeout(() => { + // Test received data + done(); + }, 0); + }); +``` + [vue-docs]: http://vuejs.org/guide/index.html [issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards @@ -100,5 +410,8 @@ for best practices while writing your Vue components and templates. [page_specific_javascript]: https://docs.gitlab.com/ce/development/frontend.html#page-specific-javascript [component-system]: https://vuejs.org/v2/guide/#Composing-with-Components [state-management]: https://vuejs.org/v2/guide/state-management.html#Simple-State-Management-from-Scratch +[one-way-data-flow]: https://vuejs.org/v2/guide/components.html#One-Way-Data-Flow [vue-resource-repo]: https://github.com/pagekit/vue-resource +[vue-resource-interceptor]: https://github.com/pagekit/vue-resource/blob/develop/docs/http.md#interceptors [issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6 +[flux]: https://facebook.github.io/flux diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 482ec54207b..166a10293c3 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -2,7 +2,7 @@ - **General Documentation**: written by the developers responsible by creating features. Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers. - **Technical Articles**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). - - **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs, in the same merge request containing code. + - **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs in the same merge request containing code. They gather all resources for that topic in a single page (user and admin documentation, articles, and third-party docs). ## Distinction between General Documentation and Technical Articles @@ -18,7 +18,7 @@ They are topic-related documentation, written with an user-friendly approach and A technical article guides users and/or admins to achieve certain objectives (within guides and tutorials), or provide an overview of that particular topic or feature (within technical overviews). It can also describe the use, implementation, or integration of third-party tools with GitLab. -They live under `doc/topics/topic-name/`, and can be searched per topic, within "Indexes per Topic" pages. The topics are listed on the main [Indexes per Topic](../topics/index.md) page. +They live under `doc/articles/article-title/index.md`, and their images should be placed under `doc/articles/article-title/img/`. Find a list of existing [technical articles](../articles/index.md) here. #### Types of Technical Articles diff --git a/doc/intro/README.md b/doc/intro/README.md index 1df6a52ce8a..d52b180a076 100644 --- a/doc/intro/README.md +++ b/doc/intro/README.md @@ -13,7 +13,7 @@ Create issues, labels, milestones, cast your vote, and review issues. - [Create a new issue](../gitlab-basics/create-issue.md) - [Assign labels to issues](../user/project/labels.md) -- [Use milestones as an overview of your project's tracker](../workflow/milestones.md) +- [Use milestones as an overview of your project's tracker](../user/project/milestones/index.md) - [Use voting to express your like/dislike to issues and merge requests](../workflow/award_emoji.md) ## Collaborate diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md index 0b17e4ff7c1..591d1524061 100644 --- a/doc/university/glossary/README.md +++ b/doc/university/glossary/README.md @@ -333,7 +333,7 @@ A [platform](https://www.meteor.com) for building javascript apps. ### Milestones -Allow you to [organize issues](https://docs.gitlab.com/ce/workflow/milestones.html) and merge requests in GitLab into a cohesive group, optionally setting a due date. A common use is keeping track of an upcoming software version. Milestones are created per-project. +Allow you to [organize issues](../../user/project/milestones/index.md) and merge requests in GitLab into a cohesive group, optionally setting a due date. A common use is keeping track of an upcoming software version. Milestones are created per-project. ### Mirror Repositories diff --git a/doc/user/admin_area/img/cohorts.png b/doc/user/admin_area/img/cohorts.png Binary files differnew file mode 100644 index 00000000000..8bae7faff07 --- /dev/null +++ b/doc/user/admin_area/img/cohorts.png diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md new file mode 100644 index 00000000000..c3f3179d99e --- /dev/null +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -0,0 +1,102 @@ +# Usage statistics + +GitLab Inc. will periodically collect information about your instance in order +to perform various actions. + +All statistics are opt-in and you can always disable them from the admin panel. + +## Version check + +GitLab can inform you when an update is available and the importance of it. + +No information other than the GitLab version and the instance's domain name +are collected. + +In the **Overview** tab you can see if your GitLab version is up to date. There +are three cases: 1) you are up to date (green), 2) there is an update available +(yellow) and 3) your version is vulnerable and a security fix is released (red). + +In any case, you will see a message informing you of the state and the +importance of the update. + +If enabled, the version status will also be shown in the help page (`/help`) +for all signed in users. + +## Usage ping + +> [Introduced][ee-557] in GitLab Enterprise Edition 8.10. More statistics +[were added][ee-735] in GitLab Enterprise Edition +8.12. [Moved to GitLab Community Edition][ce-23361] in 9.1. + +GitLab Inc. can collect non-sensitive information about how GitLab users +use their GitLab instance upon the activation of a ping feature +located in the admin panel (`/admin/application_settings`). + +You can see the **exact** JSON payload that your instance sends to GitLab +in the "Usage statistics" section of the admin panel. + +Nothing qualitative is collected. Only quantitative. That means no project +names, author names, comment bodies, names of labels, etc. + +The usage ping is sent in order for GitLab Inc. to have a better understanding +of how our users use our product, and to be more data-driven when creating or +changing features. + +The total number of the following is sent back to GitLab Inc.: + +- Comments +- Groups +- Users +- Projects +- Issues +- Labels +- CI builds +- Snippets +- Milestones +- Todos +- Pushes +- Merge requests +- Environments +- Triggers +- Deploy keys +- Pages +- Project Services +- Projects using the Prometheus service +- Issue Boards +- CI Runners +- Deployments +- Geo Nodes +- LDAP Groups +- LDAP Keys +- LDAP Users +- LFS objects +- Protected branches +- Releases +- Remote mirrors +- Uploads +- Web hooks + +Also, we track if you've installed Mattermost with GitLab. +For example: `"mattermost_enabled":true"`. + +More data will be added over time. The goal of this ping is to be as light as +possible, so it won't have any performance impact on your installation when +the calculation is made. + +### Deactivate the usage ping + +By default, usage ping is opt-out. If you want to deactivate this feature, go to +the Settings page of your administration panel and uncheck the Usage ping +checkbox. + +## Privacy policy + +GitLab Inc. does **not** collect any sensitive information, like project names +or the content of the comments. GitLab Inc. does not disclose or otherwise make +available any of the data collected on a customer specific basis. + +Read more about this in the [Privacy policy](https://about.gitlab.com/privacy). + +[ee-557]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/557 +[ee-735]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/735 +[ce-23361]: https://gitlab.com/gitlab-org/gitlab-ce/issues/23361 diff --git a/doc/user/admin_area/user_cohorts.md b/doc/user/admin_area/user_cohorts.md new file mode 100644 index 00000000000..e25e7a8bbc3 --- /dev/null +++ b/doc/user/admin_area/user_cohorts.md @@ -0,0 +1,37 @@ +# Cohorts + +> **Notes:** +> [Introduced][ce-23361] in GitLab 9.1. + +As a benefit of having the [usage ping active](settings/usage_statistics.md), +GitLab lets you analyze the users' activities of your GitLab installation. +Under `/admin/cohorts`, when the usage ping is active, GitLab will show the +monthly cohorts of new users and their activities over time. + +## Overview + +How do we read the user cohorts table? Let's take an example with the following +user cohorts. + +![User cohort example](img/cohorts.png) + +For the cohort of June 2016, 163 users have been added on this server and have +been active since this month. One month later, in July 2016, out of +these 163 users, 155 users (or 95% of the June cohort) are still active. Two +months later, 139 users (or 85%) are still active. 9 months later, we can see +that only 6% of this cohort are still active. + +The Inactive users column shows the number of users who have been added during +the month, but who have never actually had any activity in the instance. + +How do we measure the activity of users? GitLab considers a user active if: + +* the user signs in +* the user has Git activity (whether push or pull). + +## Setup + +1. [Activate the usage ping](settings/usage_statistics.md) +2. Go to `/admin/cohorts` to see the user cohorts of the server + +[ce-23361]: https://gitlab.com/gitlab-org/gitlab-ce/issues/23361 diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 3122e95fc0e..637967510f3 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -7,6 +7,9 @@ project itself, the highest permission level is used. On public and internal projects the Guest role is not enforced. All users will be able to create issues, leave comments, and pull or download the project code. +When a member leaves the team the all assigned Issues and Merge Requests +will be unassigned automatically. + GitLab administrators receive all permissions. To add or import a user, you can follow the [project users and members diff --git a/doc/user/project/integrations/kubernetes.md b/doc/user/project/integrations/kubernetes.md index f7d5e3a8ab2..73fa83d72a8 100644 --- a/doc/user/project/integrations/kubernetes.md +++ b/doc/user/project/integrations/kubernetes.md @@ -48,8 +48,12 @@ GitLab CI build environment: - `KUBE_URL` - equal to the API URL - `KUBE_TOKEN` -- `KUBE_NAMESPACE` -- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path to a file containing PEM data. +- `KUBE_NAMESPACE` - The Kubernetes namespace is auto-generated if not specified. + The default value is `<project_name>-<project_id>`. You can overwrite it to + use different one if needed, otherwise the `KUBE_NAMESPACE` variable will + receive the default value. +- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path + to a file containing PEM data. - `KUBE_CA_PEM` (deprecated)- only if a custom CA bundle was specified. Raw PEM data. ## Web terminals diff --git a/doc/user/project/milestones/img/milestone_create.png b/doc/user/project/milestones/img/milestone_create.png Binary files differnew file mode 100644 index 00000000000..beb2caa897f --- /dev/null +++ b/doc/user/project/milestones/img/milestone_create.png diff --git a/doc/user/project/milestones/img/milestone_group_create.png b/doc/user/project/milestones/img/milestone_group_create.png Binary files differnew file mode 100644 index 00000000000..7aaa7c56c15 --- /dev/null +++ b/doc/user/project/milestones/img/milestone_group_create.png diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md new file mode 100644 index 00000000000..9502b9885c1 --- /dev/null +++ b/doc/user/project/milestones/index.md @@ -0,0 +1,49 @@ +# Milestones + +Milestones allow you to organize issues and merge requests into a cohesive group, +optionally setting a due date. A common use is keeping track of an upcoming +software version. Milestones can be created per-project or per-group. + +## Creating a project milestone + +>**Note:** +You need [Master permissions](../../permissions.md) in order to create a milestone. + +You can find the milestones page under your project's **Issues âž” Milestones**. +To create a new milestone, simply click the **New milestone** button when in the +milestones page. A milestone can have a title, a description and start/due dates. +Once you fill in all the details, hit the **Create milestone** button. + +>**Note:** +The start/due dates are required if you intend to use [Burndown charts](#burndown-charts). + +![Creating a milestone](img/milestone_create.png) + +## Creating a group milestone + +>**Note:** +You need [Master permissions](../../permissions.md) in order to create a milestone. + +You can create a milestone for several projects in the same group simultaneously. +On the group's **Issues âž” Milestones** page, you will be able to see the status +of that milestone across all of the selected projects. To create a new milestone +for selected projects in the group, click the **New milestone** button. The +form is the same as when creating a milestone for a specific project with the +addition of the selection of the projects you want to inherit this milestone. + +![Creating a group milestone](img/milestone_group_create.png) + +## Special milestone filters + +In addition to the milestones that exist in the project or group, there are some +special options available when filtering by milestone: + +* **No Milestone** - only show issues or merge requests without a milestone. +* **Upcoming** - show issues or merge request that belong to the next open + milestone with a due date, by project. (For example: if project A has + milestone v1 due in three days, and project B has milestone v2 due in a week, + then this will show issues or merge requests from milestone v1 in project A + and milestone v2 in project B.) +* **Started** - show issues or merge requests from any milestone with a start + date less than today. Note that this can return results from several + milestones in the same project. diff --git a/doc/workflow/README.md b/doc/workflow/README.md index a1852650cfb..375b334c3b6 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -27,7 +27,7 @@ - [Time tracking](time_tracking.md) - [Web Editor](../user/project/repository/web_editor.md) - [Releases](releases.md) -- [Milestones](milestones.md) +- [Milestones](../user/project/milestones/index.md) - [Merge Requests](../user/project/merge_requests/index.md) - [Authorization for merge requests](../user/project/merge_requests/authorization_for_merge_requests.md) - [Cherry-pick changes](../user/project/merge_requests/cherry_pick_changes.md) diff --git a/doc/workflow/groups.md b/doc/workflow/groups.md index 882747e14e9..1cb3c940f00 100644 --- a/doc/workflow/groups.md +++ b/doc/workflow/groups.md @@ -1,6 +1,6 @@ # GitLab Groups -GitLab groups allow you to group projects into directories and give users to several projects at once. +GitLab groups allow you to group projects into directories and give users access to several projects at once. When you create a new project in GitLab, the default namespace for the project is the personal namespace associated with your GitLab user. In this document we will see how to create groups, put projects in groups and manage who can access the projects in a group. diff --git a/doc/workflow/milestones.md b/doc/workflow/milestones.md index 37afe553e55..69eb6b286b0 100644 --- a/doc/workflow/milestones.md +++ b/doc/workflow/milestones.md @@ -1,28 +1 @@ -# Milestones - -Milestones allow you to organize issues and merge requests into a cohesive group, optionally setting a due date. -A common use is keeping track of an upcoming software version. Milestones are created per-project. - -![milestone form](milestones/form.png) - -## Groups and milestones - -You can create a milestone for several projects in the same group simultaneously. -On the group's milestones page, you will be able to see the status of that milestone across all of the selected projects. - -![group milestone form](milestones/group_form.png) - -## Special milestone filters - -In addition to the milestones that exist in the project or group, there are some -special options available when filtering by milestone: - -* **No Milestone** - only show issues or merge requests without a milestone. -* **Upcoming** - show issues or merge request that belong to the next open - milestone with a due date, by project. (For example: if project A has - milestone v1 due in three days, and project B has milestone v2 due in a week, - then this will show issues or merge requests from milestone v1 in project A - and milestone v2 in project B.) -* **Started** - show issues or merge requests from any milestone with a start - date less than today. Note that this can return results from several - milestones in the same project. +This document was moved to [another location](../user/project/milestones/index.md). diff --git a/doc/workflow/milestones/form.png b/doc/workflow/milestones/form.png Binary files differdeleted file mode 100644 index c4731d88543..00000000000 --- a/doc/workflow/milestones/form.png +++ /dev/null diff --git a/doc/workflow/milestones/group_form.png b/doc/workflow/milestones/group_form.png Binary files differdeleted file mode 100644 index dccdb019703..00000000000 --- a/doc/workflow/milestones/group_form.png +++ /dev/null diff --git a/features/group/members.feature b/features/group/members.feature index 1f9514bac39..e539f6a1273 100644 --- a/features/group/members.feature +++ b/features/group/members.feature @@ -4,40 +4,6 @@ Feature: Group Members And "John Doe" is owner of group "Owned" And "John Doe" is guest of group "Guest" - @javascript - Scenario: I should add user to group "Owned" - Given User "Mary Jane" exists - When I visit group "Owned" members page - And I select user "Mary Jane" from list with role "Reporter" - Then I should see user "Mary Jane" in team list - - @javascript - Scenario: Add user to group - Given gitlab user "Mike" - When I visit group "Owned" members page - When I select "Mike" as "Reporter" - Then I should see "Mike" in team list as "Reporter" - - @javascript - Scenario: Ignore add user to group when is already Owner - Given gitlab user "Mike" - When I visit group "Owned" members page - When I select "Mike" as "Reporter" - Then I should see "Mike" in team list as "Owner" - - @javascript - Scenario: Invite user to group - When I visit group "Owned" members page - When I select "sjobs@apple.com" as "Reporter" - Then I should see "sjobs@apple.com" in team list as invited "Reporter" - - @javascript - Scenario: Edit group member permissions - Given "Mary Jane" is guest of group "Owned" - And I visit group "Owned" members page - When I change the "Mary Jane" role to "Developer" - Then I should see "Mary Jane" as "Developer" - # Leave @javascript diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 27fa67c1843..4dee0cd23dc 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -177,9 +177,3 @@ Feature: Project Issues And I should not see labels field And I submit new issue "500 error on profile" Then I should see issue "500 error on profile" - - @javascript - Scenario: Another user adds a comment to issue I'm currently viewing - Given I visit issue page "Release 0.4" - And another user adds a comment with text "Yay!" to issue "Release 0.4" - Then I should see a new comment with text "Yay!" diff --git a/features/project/merge_requests/revert.feature b/features/project/merge_requests/revert.feature index ec6666f227f..aaac5fd7209 100644 --- a/features/project/merge_requests/revert.feature +++ b/features/project/merge_requests/revert.feature @@ -25,7 +25,5 @@ Feature: Revert Merge Requests @javascript Scenario: I revert a merge request in a new merge request Given I click on the revert button - And I am on the Merge Request detail page - And I click on the revert button And I revert the changes in a new merge request Then I should see the new merge request notice diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index 894c4a96bb8..536c24b6882 100644 --- a/features/project/source/browse_files.feature +++ b/features/project/source/browse_files.feature @@ -135,7 +135,7 @@ Feature: Project Source Browse Files And I fill the commit message And I click on "Commit changes" Then I am on the new file page - And I see a commit error message + And I see "Path can contain only..." @javascript Scenario: I can create file with a directory name diff --git a/features/project/team_management.feature b/features/project/team_management.feature index 5888662fc3f..aed41924cd9 100644 --- a/features/project/team_management.feature +++ b/features/project/team_management.feature @@ -7,26 +7,6 @@ Feature: Project Team Management And "Dmitriy" is "Shop" developer And I visit project "Shop" team page - Scenario: See all team members - Then I should be able to see myself in team - And I should see "Dmitriy" in team list - - @javascript - Scenario: Add user to project - When I select "Mike" as "Reporter" - Then I should see "Mike" in team list as "Reporter" - - @javascript - Scenario: Invite user to project - When I select "sjobs@apple.com" as "Reporter" - Then I should see "sjobs@apple.com" in team list as invited "Reporter" - - @javascript - Scenario: Update user access - Given I should see "Dmitriy" in team list as "Developer" - And I change "Dmitriy" role to "Reporter" - And I should see "Dmitriy" in team list as "Reporter" - Scenario: Cancel team member Given I click cancel link for "Dmitriy" Then I visit project "Shop" team page diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index adaf375453c..b04a7015d4e 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -4,71 +4,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps include SharedPaths include SharedGroup include SharedUser - include Select2Helper - - step 'I select "Mike" as "Reporter"' do - user = User.find_by(name: "Mike") - - page.within ".users-group-form" do - select2(user.id, from: "#user_ids", multiple: true) - select "Reporter", from: "access_level" - end - - click_button "Add to group" - end - - step 'I select "Mike" as "Master"' do - user = User.find_by(name: "Mike") - - page.within ".users-group-form" do - select2(user.id, from: "#user_ids", multiple: true) - select "Master", from: "access_level" - end - - click_button "Add to group" - end - - step 'I should see "Mike" in team list as "Reporter"' do - page.within '.content-list' do - expect(page).to have_content('Mike') - expect(page).to have_content('Reporter') - end - end - - step 'I should see "Mike" in team list as "Owner"' do - page.within '.content-list' do - expect(page).to have_content('Mike') - expect(page).to have_content('Owner') - end - end - - step 'I select "sjobs@apple.com" as "Reporter"' do - page.within ".users-group-form" do - select2("sjobs@apple.com", from: "#user_ids", multiple: true) - select "Reporter", from: "access_level" - end - - click_button "Add to group" - end - - step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do - page.within '.content-list' do - expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('Invited') - expect(page).to have_content('Reporter') - end - end - - step 'I select user "Mary Jane" from list with role "Reporter"' do - user = User.find_by(name: "Mary Jane") || create(:user, name: "Mary Jane") - - page.within ".users-group-form" do - select2(user.id, from: "#user_ids", multiple: true) - select "Reporter", from: "access_level" - end - - click_button "Add to group" - end step 'I should see user "John Doe" in team list' do expect(group_members_list).to have_content("John Doe") @@ -87,7 +22,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps end step 'I click on the "Remove User From Group" button for "John Doe"' do - find(:css, 'li', text: "John Doe").find(:css, 'a.btn-remove').click + find(:css, '.project-members-page li', text: "John Doe").find(:css, 'a.btn-remove').click # poltergeist always confirms popups. end @@ -97,7 +32,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps end step 'I should not see the "Remove User From Group" button for "John Doe"' do - expect(find(:css, 'li', text: "John Doe")).not_to have_selector(:css, 'a.btn-remove') + expect(find(:css, '.project-members-page li', text: "John Doe")).not_to have_selector(:css, 'a.btn-remove') # poltergeist always confirms popups. end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index c0dc48f1bb2..637e6568267 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -345,17 +345,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end end - step 'another user adds a comment with text "Yay!" to issue "Release 0.4"' do - issue = Issue.find_by!(title: 'Release 0.4') - create(:note_on_issue, noteable: issue, project: project, note: 'Yay!') - end - - step 'I should see a new comment with text "Yay!"' do - page.within '#notes' do - expect(page).to have_content('Yay!') - end - end - def filter_issue(text) fill_in 'issuable_search', with: text end diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 5bd3c1a1246..d52fa10c337 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -284,7 +284,11 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I see "Unable to create directory"' do - expect(page).to have_content('Directory already exists') + expect(page).to have_content('A directory with this name already exists') + end + + step 'I see "Path can contain only..."' do + expect(page).to have_content('Path can contain only') end step 'I see a commit error message' do diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index 6986c7ede56..ff4c9deee2a 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -4,25 +4,10 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps include SharedPaths include Select2Helper - step 'I should be able to see myself in team' do - expect(page).to have_content(@user.name) - expect(page).to have_content(@user.username) - end - - step 'I should see "Dmitriy" in team list' do + step 'I should not see "Dmitriy" in team list' do user = User.find_by(name: "Dmitriy") - expect(page).to have_content(user.name) - expect(page).to have_content(user.username) - end - - step 'I select "Mike" as "Reporter"' do - user = User.find_by(name: "Mike") - - page.within ".users-project-form" do - select2(user.id, from: "#user_ids", multiple: true) - select "Reporter", from: "access_level" - end - click_button "Add to project" + expect(page).not_to have_content(user.name) + expect(page).not_to have_content(user.username) end step 'I should see "Mike" in team list as "Reporter"' do @@ -34,60 +19,6 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end end - step 'I select "sjobs@apple.com" as "Reporter"' do - page.within ".users-project-form" do - find('#user_ids', visible: false).set('sjobs@apple.com') - select "Reporter", from: "access_level" - end - click_button "Add to project" - end - - step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do - project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com') - page.within "#project_member_#{project_member.id}" do - expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('Invited') - expect(page).to have_content('Reporter') - end - end - - step 'I should see "Dmitriy" in team list as "Developer"' do - user = User.find_by(name: 'Dmitriy') - project_member = project.project_members.find_by(user_id: user.id) - page.within "#project_member_#{project_member.id}" do - expect(page).to have_content('Dmitriy') - expect(page).to have_content('Developer') - end - end - - step 'I change "Dmitriy" role to "Reporter"' do - project = Project.find_by(name: "Shop") - user = User.find_by(name: 'Dmitriy') - project_member = project.project_members.find_by(user_id: user.id) - page.within "#project_member_#{project_member.id}" do - click_button project_member.human_access - - page.within '.dropdown-menu' do - click_link 'Reporter' - end - end - end - - step 'I should see "Dmitriy" in team list as "Reporter"' do - user = User.find_by(name: 'Dmitriy') - project_member = project.project_members.find_by(user_id: user.id) - page.within "#project_member_#{project_member.id}" do - expect(page).to have_content('Dmitriy') - expect(page).to have_content('Reporter') - end - end - - step 'I should not see "Dmitriy" in team list' do - user = User.find_by(name: "Dmitriy") - expect(page).not_to have_content(user.name) - expect(page).not_to have_content(user.username) - end - step 'gitlab user "Mike"' do create(:user, name: "Mike") end @@ -113,7 +44,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps project.team << [user, :reporter] end - step 'I click link "Import team from another project"' do + step 'I click link "Import team from another project"' do page.within '.users-project-form' do click_link "Import" end diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 66b37fd2bcc..621b9dcecd9 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -62,7 +62,7 @@ module API post ":id/repository/commits" do authorize! :push_code, user_project - attrs = declared_params.merge(start_branch: declared_params[:branch], target_branch: declared_params[:branch]) + attrs = declared_params.merge(start_branch: declared_params[:branch], branch_name: declared_params[:branch]) result = ::Files::MultiService.new(user_project, current_user, attrs).execute @@ -140,7 +140,7 @@ module API commit_params = { commit: commit, start_branch: params[:branch], - target_branch: params[:branch] + branch_name: params[:branch] } result = ::Commits::CherryPickService.new(user_project, current_user, commit_params).execute diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 9919762cd82..64ab6f01eb5 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -18,6 +18,12 @@ module API expose :bio, :location, :skype, :linkedin, :twitter, :website_url, :organization end + class UserActivity < Grape::Entity + expose :username + expose :last_activity_on + expose :last_activity_on, as: :last_activity_at # Back-compat + end + class Identity < Grape::Entity expose :provider, :extern_uid end @@ -25,6 +31,7 @@ module API class UserPublic < User expose :last_sign_in_at expose :confirmed_at + expose :last_activity_on expose :email expose :color_scheme_id, :projects_limit, :current_sign_in_at expose :identities, using: Entities::Identity diff --git a/lib/api/files.rb b/lib/api/files.rb index 33fc970dc09..e6ea12c5ab7 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -5,7 +5,7 @@ module API { file_path: attrs[:file_path], start_branch: attrs[:branch], - target_branch: attrs[:branch], + branch_name: attrs[:branch], commit_message: attrs[:commit_message], file_content: attrs[:content], file_content_encoding: attrs[:encoding], @@ -130,7 +130,7 @@ module API authorize! :push_code, user_project file_params = declared_params(include_missing: false) - result = ::Files::DestroyService.new(user_project, current_user, commit_params(file_params)).execute + result = ::Files::DeleteService.new(user_project, current_user, commit_params(file_params)).execute if result[:status] != :success render_api_error!(result[:message], 400) diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 810e5063996..718f936a1fc 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -60,6 +60,12 @@ module API rescue JSON::ParserError {} end + + def log_user_activity(actor) + commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS + + ::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action]) + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 215bc03d0e9..5b48ee8665f 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -40,6 +40,8 @@ module API response = { status: access_status.status, message: access_status.message } if access_status.status + log_user_activity(actor) + # Return the repository full path so that gitlab-shell has it when # handling ssh commands response[:repository_path] = diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 05423c17449..244725bb292 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -35,7 +35,7 @@ module API optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' optional :labels, type: String, desc: 'Comma-separated list of label names' - optional :due_date, type: String, desc: 'Date time string in the format YEAR-MONTH-DAY' + optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' optional :confidential, type: Boolean, desc: 'Boolean parameter if the issue should be confidential' end diff --git a/lib/api/users.rb b/lib/api/users.rb index eedc59f8636..46f221f68fe 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -39,10 +39,13 @@ module API params do # CE optional :username, type: String, desc: 'Get a single user with a specific username' + optional :extern_uid, type: String, desc: 'Get a single user with a specific external authentication provider UID' + optional :provider, type: String, desc: 'The external provider' optional :search, type: String, desc: 'Search for a username' optional :active, type: Boolean, default: false, desc: 'Filters only active users' optional :external, type: Boolean, default: false, desc: 'Filters only external users' optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users' + all_or_none_of :extern_uid, :provider use :pagination end @@ -51,14 +54,17 @@ module API render_api_error!("Not authorized.", 403) end - if params[:username].present? - users = User.where(username: params[:username]) - else - users = User.all - users = users.active if params[:active] - users = users.search(params[:search]) if params[:search].present? - users = users.blocked if params[:blocked] - users = users.external if params[:external] && current_user.admin? + authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?) + + users = User.all + users = User.where(username: params[:username]) if params[:username] + users = users.active if params[:active] + users = users.search(params[:search]) if params[:search].present? + users = users.blocked if params[:blocked] + + if current_user.admin? + users = users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) if params[:extern_uid] && params[:provider] + users = users.external if params[:external] end entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic @@ -534,6 +540,21 @@ module API email.destroy current_user.update_secondary_emails! end + + desc 'Get a list of user activities' + params do + optional :from, type: DateTime, default: 6.months.ago, desc: 'Date string in the format YEAR-MONTH-DAY' + use :pagination + end + get "activities" do + authenticated_as_admin! + + activities = User. + where(User.arel_table[:last_activity_on].gteq(params[:from])). + reorder(last_activity_on: :asc) + + present paginate(activities), with: Entities::UserActivity + end end end end diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb index 3414a2883e5..674de592f0a 100644 --- a/lib/api/v3/commits.rb +++ b/lib/api/v3/commits.rb @@ -53,7 +53,7 @@ module API attrs = declared_params.dup branch = attrs.delete(:branch_name) - attrs.merge!(branch: branch, start_branch: branch, target_branch: branch) + attrs.merge!(start_branch: branch, branch_name: branch) result = ::Files::MultiService.new(user_project, current_user, attrs).execute @@ -131,7 +131,7 @@ module API commit_params = { commit: commit, start_branch: params[:branch], - target_branch: params[:branch] + branch_name: params[:branch] } result = ::Commits::CherryPickService.new(user_project, current_user, commit_params).execute diff --git a/lib/api/v3/files.rb b/lib/api/v3/files.rb index 13542b0c71c..c76acc86504 100644 --- a/lib/api/v3/files.rb +++ b/lib/api/v3/files.rb @@ -6,7 +6,7 @@ module API { file_path: attrs[:file_path], start_branch: attrs[:branch], - target_branch: attrs[:branch], + branch_name: attrs[:branch], commit_message: attrs[:commit_message], file_content: attrs[:content], file_content_encoding: attrs[:encoding], @@ -123,7 +123,7 @@ module API file_params = declared_params(include_missing: false) file_params[:branch] = file_params.delete(:branch_name) - result = ::Files::DestroyService.new(user_project, current_user, commit_params(file_params)).execute + result = ::Files::DeleteService.new(user_project, current_user, commit_params(file_params)).execute if result[:status] == :success status(200) diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb index 0b2b8bd7f4d..327ea9449a1 100644 --- a/lib/banzai/filter/issuable_state_filter.rb +++ b/lib/banzai/filter/issuable_state_filter.rb @@ -9,12 +9,14 @@ module Banzai VISIBLE_STATES = %w(closed merged).freeze def call + return doc unless context[:issuable_state_filter_enabled] + extractor = Banzai::IssuableExtractor.new(project, current_user) issuables = extractor.extract([doc]) issuables.each do |node, issuable| - if VISIBLE_STATES.include?(issuable.state) && node.children.present? - node.add_child(Nokogiri::XML::Text.new(" [#{issuable.state}]", doc)) + if VISIBLE_STATES.include?(issuable.state) && node.inner_html == issuable.reference_link_text(project) + node.content += " (#{issuable.state})" end end diff --git a/lib/banzai/filter/plantuml_filter.rb b/lib/banzai/filter/plantuml_filter.rb index b2537117558..5325819d828 100644 --- a/lib/banzai/filter/plantuml_filter.rb +++ b/lib/banzai/filter/plantuml_filter.rb @@ -7,14 +7,14 @@ module Banzai # class PlantumlFilter < HTML::Pipeline::Filter def call - return doc unless doc.at('pre.plantuml') && settings.plantuml_enabled + return doc unless doc.at('pre > code[lang="plantuml"]') && settings.plantuml_enabled plantuml_setup - doc.css('pre.plantuml').each do |el| + doc.css('pre > code[lang="plantuml"]').each do |node| img_tag = Nokogiri::HTML::DocumentFragment.parse( - Asciidoctor::PlantUml::Processor.plantuml_content(el.content, {})) - el.replace img_tag + Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {})) + node.parent.replace(img_tag) end doc diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb index 0e22f2189ee..c66b0435f3a 100644 --- a/lib/gitlab/email/handler/create_note_handler.rb +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -7,6 +7,8 @@ module Gitlab class CreateNoteHandler < BaseHandler include ReplyProcessing + delegate :project, to: :sent_notification, allow_nil: true + def can_handle? mail_key =~ /\A\w+\z/ end @@ -32,10 +34,6 @@ module Gitlab sent_notification.recipient end - def project - sent_notification.project - end - def sent_notification @sent_notification ||= SentNotification.for(mail_key) end diff --git a/lib/gitlab/email/handler/unsubscribe_handler.rb b/lib/gitlab/email/handler/unsubscribe_handler.rb index 97d7a8d65ff..df491f060bf 100644 --- a/lib/gitlab/email/handler/unsubscribe_handler.rb +++ b/lib/gitlab/email/handler/unsubscribe_handler.rb @@ -4,6 +4,8 @@ module Gitlab module Email module Handler class UnsubscribeHandler < BaseHandler + delegate :project, to: :sent_notification, allow_nil: true + def can_handle? mail_key =~ /\A\w+#{Regexp.escape(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX)}\z/ end diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index ec0529b5a4b..bb4fdd1f1f4 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -32,6 +32,10 @@ module Gitlab raise UnknownIncomingEmail unless handler + Gitlab::Metrics.add_event(:receive_email, + project: handler.try(:project), + handler: handler.class.name) + handler.execute end diff --git a/lib/gitlab/git/index.rb b/lib/gitlab/git/index.rb index af1744c9c46..1add037fa5f 100644 --- a/lib/gitlab/git/index.rb +++ b/lib/gitlab/git/index.rb @@ -1,8 +1,12 @@ module Gitlab module Git class Index + IndexError = Class.new(StandardError) + DEFAULT_MODE = 0o100644 + ACTIONS = %w(create create_dir update move delete).freeze + attr_reader :repository, :raw_index def initialize(repository) @@ -23,9 +27,8 @@ module Gitlab def create(options) options = normalize_options(options) - file_entry = get(options[:file_path]) - if file_entry - raise Gitlab::Git::Repository::InvalidBlobName.new("Filename already exists") + if get(options[:file_path]) + raise IndexError, "A file with this name already exists" end add_blob(options) @@ -34,13 +37,12 @@ module Gitlab def create_dir(options) options = normalize_options(options) - file_entry = get(options[:file_path]) - if file_entry - raise Gitlab::Git::Repository::InvalidBlobName.new("Directory already exists as a file") + if get(options[:file_path]) + raise IndexError, "A file with this name already exists" end if dir_exists?(options[:file_path]) - raise Gitlab::Git::Repository::InvalidBlobName.new("Directory already exists") + raise IndexError, "A directory with this name already exists" end options = options.dup @@ -55,7 +57,7 @@ module Gitlab file_entry = get(options[:file_path]) unless file_entry - raise Gitlab::Git::Repository::InvalidBlobName.new("File doesn't exist") + raise IndexError, "A file with this name doesn't exist" end add_blob(options, mode: file_entry[:mode]) @@ -66,7 +68,11 @@ module Gitlab file_entry = get(options[:previous_path]) unless file_entry - raise Gitlab::Git::Repository::InvalidBlobName.new("File doesn't exist") + raise IndexError, "A file with this name doesn't exist" + end + + if get(options[:file_path]) + raise IndexError, "A file with this name already exists" end raw_index.remove(options[:previous_path]) @@ -77,9 +83,8 @@ module Gitlab def delete(options) options = normalize_options(options) - file_entry = get(options[:file_path]) - unless file_entry - raise Gitlab::Git::Repository::InvalidBlobName.new("File doesn't exist") + unless get(options[:file_path]) + raise IndexError, "A file with this name doesn't exist" end raw_index.remove(options[:file_path]) @@ -95,10 +100,20 @@ module Gitlab end def normalize_path(path) + unless path + raise IndexError, "You must provide a file path" + end + pathname = Gitlab::Git::PathHelper.normalize_path(path.dup) - if pathname.each_filename.include?('..') - raise Gitlab::Git::Repository::InvalidBlobName.new('Invalid path') + pathname.each_filename do |segment| + if segment == '..' + raise IndexError, 'Path cannot include directory traversal' + end + + unless segment =~ Gitlab::Regex.file_name_regex + raise IndexError, "Path #{Gitlab::Regex.file_name_regex_message}" + end end pathname.to_s @@ -106,6 +121,10 @@ module Gitlab def add_blob(options, mode: nil) content = options[:content] + unless content + raise IndexError, "You must provide content" + end + content = Base64.decode64(content) if options[:encoding] == 'base64' detect = CharlockHolmes::EncodingDetector.new.detect(content) @@ -119,7 +138,7 @@ module Gitlab raw_index.add(path: options[:file_path], oid: oid, mode: mode || DEFAULT_MODE) rescue Rugged::IndexError => e - raise Gitlab::Git::Repository::InvalidBlobName.new(e.message) + raise IndexError, e.message end end end diff --git a/lib/gitlab/markup_helper.rb b/lib/gitlab/markup_helper.rb index dda371e6554..49285e35251 100644 --- a/lib/gitlab/markup_helper.rb +++ b/lib/gitlab/markup_helper.rb @@ -1,6 +1,11 @@ module Gitlab module MarkupHelper - module_function + extend self + + MARKDOWN_EXTENSIONS = %w(mdown mkd mkdn md markdown).freeze + ASCIIDOC_EXTENSIONS = %w(adoc ad asciidoc).freeze + OTHER_EXTENSIONS = %w(textile rdoc org creole wiki mediawiki rst).freeze + EXTENSIONS = MARKDOWN_EXTENSIONS + ASCIIDOC_EXTENSIONS + OTHER_EXTENSIONS # Public: Determines if a given filename is compatible with GitHub::Markup. # @@ -8,10 +13,7 @@ module Gitlab # # Returns boolean def markup?(filename) - gitlab_markdown?(filename) || - asciidoc?(filename) || - filename.downcase.end_with?(*%w(.textile .rdoc .org .creole .wiki - .mediawiki .rst)) + EXTENSIONS.include?(extension(filename)) end # Public: Determines if a given filename is compatible with @@ -21,7 +23,7 @@ module Gitlab # # Returns boolean def gitlab_markdown?(filename) - filename.downcase.end_with?(*%w(.mdown .mkd .mkdn .md .markdown)) + MARKDOWN_EXTENSIONS.include?(extension(filename)) end # Public: Determines if the given filename has AsciiDoc extension. @@ -30,7 +32,7 @@ module Gitlab # # Returns boolean def asciidoc?(filename) - filename.downcase.end_with?(*%w(.adoc .ad .asciidoc)) + ASCIIDOC_EXTENSIONS.include?(extension(filename)) end # Public: Determines if the given filename is plain text. @@ -39,12 +41,17 @@ module Gitlab # # Returns boolean def plain?(filename) - filename.downcase.end_with?('.txt') || - filename.casecmp('readme').zero? + extension(filename) == 'txt' || filename.casecmp('readme').zero? end def previewable?(filename) markup?(filename) end + + private + + def extension(filename) + File.extname(filename).downcase.delete('.') + end end end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index e599dd4a656..08b061d5e31 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -73,22 +73,6 @@ module Gitlab "can contain only letters, digits, '_', '-', '@', '+' and '.'." end - def file_path_regex - @file_path_regex ||= /\A[[[:alnum:]]_\-\.\/\@]*\z/.freeze - end - - def file_path_regex_message - "can contain only letters, digits, '_', '-', '@' and '.'. Separate directories with a '/'." - end - - def directory_traversal_regex - @directory_traversal_regex ||= /\.{2}/.freeze - end - - def directory_traversal_regex_message - "cannot include directory traversal." - end - def archive_formats_regex # |zip|tar| tar.gz | tar.bz2 | @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb new file mode 100644 index 00000000000..6aca6db3123 --- /dev/null +++ b/lib/gitlab/usage_data.rb @@ -0,0 +1,65 @@ +module Gitlab + class UsageData + include Gitlab::CurrentSettings + + class << self + def data(force_refresh: false) + Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) { uncached_data } + end + + def uncached_data + license_usage_data.merge(system_usage_data) + end + + def to_json(force_refresh: false) + data(force_refresh: force_refresh).to_json + end + + def system_usage_data + { + counts: { + boards: Board.count, + ci_builds: ::Ci::Build.count, + ci_pipelines: ::Ci::Pipeline.count, + ci_runners: ::Ci::Runner.count, + ci_triggers: ::Ci::Trigger.count, + deploy_keys: DeployKey.count, + deployments: Deployment.count, + environments: Environment.count, + groups: Group.count, + issues: Issue.count, + keys: Key.count, + labels: Label.count, + lfs_objects: LfsObject.count, + merge_requests: MergeRequest.count, + milestones: Milestone.count, + notes: Note.count, + pages_domains: PagesDomain.count, + projects: Project.count, + projects_prometheus_active: PrometheusService.active.count, + protected_branches: ProtectedBranch.count, + releases: Release.count, + services: Service.where(active: true).count, + snippets: Snippet.count, + todos: Todo.count, + uploads: Upload.count, + web_hooks: WebHook.count + } + } + end + + def license_usage_data + usage_data = { + uuid: current_application_settings.uuid, + version: Gitlab::VERSION, + active_user_count: User.active.count, + recorded_at: Time.now, + mattermost_enabled: Gitlab.config.mattermost.enabled, + edition: 'CE' + } + + usage_data + end + end + end +end diff --git a/lib/gitlab/user_activities.rb b/lib/gitlab/user_activities.rb new file mode 100644 index 00000000000..eb36ab9fded --- /dev/null +++ b/lib/gitlab/user_activities.rb @@ -0,0 +1,34 @@ +module Gitlab + class UserActivities + include Enumerable + + KEY = 'users:activities'.freeze + BATCH_SIZE = 500 + + def self.record(key, time = Time.now) + Gitlab::Redis.with do |redis| + redis.hset(KEY, key, time.to_i) + end + end + + def delete(*keys) + Gitlab::Redis.with do |redis| + redis.hdel(KEY, keys) + end + end + + def each + cursor = 0 + loop do + cursor, pairs = + Gitlab::Redis.with do |redis| + redis.hscan(KEY, cursor, count: BATCH_SIZE) + end + + Hash[pairs].each { |pair| yield pair } + + break if cursor == '0' + end + end + end +end diff --git a/package.json b/package.json index a17399ddb8f..e65f30eea77 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "eslint-plugin-filenames": "^1.1.0", "eslint-plugin-import": "^2.2.0", "eslint-plugin-jasmine": "^2.1.0", + "eslint-plugin-promise": "^3.5.0", "istanbul": "^0.4.5", "jasmine-core": "^2.5.2", "jasmine-jquery": "^2.1.1", diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb index 5dd8f66343f..2565622f8df 100644 --- a/spec/controllers/admin/application_settings_controller_spec.rb +++ b/spec/controllers/admin/application_settings_controller_spec.rb @@ -3,12 +3,49 @@ require 'spec_helper' describe Admin::ApplicationSettingsController do include StubENV + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } let(:admin) { create(:admin) } + let(:user) { create(:user)} before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') end + describe 'GET #usage_data with no access' do + before do + sign_in(user) + end + + it 'returns 404' do + get :usage_data, format: :html + + expect(response.status).to eq(404) + end + end + + describe 'GET #usage_data' do + before do + sign_in(admin) + end + + it 'returns HTML data' do + get :usage_data, format: :html + + expect(response.body).to start_with('<span') + expect(response.status).to eq(200) + end + + it 'returns JSON data' do + get :usage_data, format: :json + + body = JSON.parse(response.body) + expect(body["version"]).to eq(Gitlab::VERSION) + expect(body).to include('counts') + expect(response.status).to eq(200) + end + end + describe 'PUT #update' do before do sign_in(admin) diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 3e9f272a0d8..0fd09d156c4 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -106,7 +106,7 @@ describe Projects::BlobController do namespace_id: project.namespace, project_id: project, id: 'master/CHANGELOG', - target_branch: 'master', + branch_name: 'master', content: 'Added changes', commit_message: 'Update CHANGELOG' } @@ -178,7 +178,7 @@ describe Projects::BlobController do context 'when editing on the original repository' do it "redirects to forked project new merge request" do - default_params[:target_branch] = "fork-test-1" + default_params[:branch_name] = "fork-test-1" default_params[:create_merge_request] = 1 put :update, default_params diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 416eaa0037e..a4b4392d7cc 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -55,7 +55,7 @@ describe Projects::ProjectMembersController do user_ids: '', access_level: Gitlab::Access::GUEST - expect(response).to set_flash.to 'No users or groups specified.' + expect(response).to set_flash.to 'No users specified.' expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project)) end end @@ -225,7 +225,7 @@ describe Projects::ProjectMembersController do id: member expect(response).to redirect_to( - namespace_project_project_members_path(project.namespace, project) + namespace_project_settings_members_path(project.namespace, project) ) expect(project.members).to include member end diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb index ab94e292e48..a43dad5756d 100644 --- a/spec/controllers/projects/tree_controller_spec.rb +++ b/spec/controllers/projects/tree_controller_spec.rb @@ -97,29 +97,29 @@ describe Projects::TreeController do project_id: project, id: 'master', dir_name: path, - target_branch: target_branch, + branch_name: branch_name, commit_message: 'Test commit message') end context 'successful creation' do let(:path) { 'files/new_dir'} - let(:target_branch) { 'master-test'} + let(:branch_name) { 'master-test'} it 'redirects to the new directory' do expect(subject). - to redirect_to("/#{project.path_with_namespace}/tree/#{target_branch}/#{path}") + to redirect_to("/#{project.path_with_namespace}/tree/#{branch_name}/#{path}") expect(flash[:notice]).to eq('The directory has been successfully created.') end end context 'unsuccessful creation' do let(:path) { 'README.md' } - let(:target_branch) { 'master'} + let(:branch_name) { 'master'} it 'does not allow overwriting of existing files' do expect(subject). to redirect_to("/#{project.path_with_namespace}/tree/master") - expect(flash[:alert]).to eq('Directory already exists as a file') + expect(flash[:alert]).to eq('A file with this name already exists') end end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 9c16a7bc08b..038132cffe0 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -16,7 +16,9 @@ describe SessionsController do end end - context 'when using valid password' do + context 'when using valid password', :redis do + include UserActivitiesHelpers + let(:user) { create(:user) } it 'authenticates user correctly' do @@ -37,6 +39,12 @@ describe SessionsController do subject.sign_out user end end + + it 'updates the user activity' do + expect do + post(:create, user: { login: user.username, password: user.password }) + end.to change { user_activity(user) } + end end end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 361f9dac191..253a025af48 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -40,6 +40,10 @@ FactoryGirl.define do state :closed end + trait :opened do + state :opened + end + trait :reopened do state :reopened end diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 87a8f62687a..9d205104ebe 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -109,7 +109,7 @@ describe "Admin::Projects", feature: true do expect(page).to have_content('Developer') end - find(:css, 'li', text: current_user.name).find(:css, 'a.btn-remove').click + find(:css, '.content-list li', text: current_user.name).find(:css, 'a.btn-remove').click expect(page).not_to have_selector(:css, '.content-list') end diff --git a/spec/features/groups/members/list_spec.rb b/spec/features/groups/members/list_spec.rb index 14c193f7450..543879bd21d 100644 --- a/spec/features/groups/members/list_spec.rb +++ b/spec/features/groups/members/list_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Groups members list', feature: true do + include Select2Helper + let(:user1) { create(:user, name: 'John Doe') } let(:user2) { create(:user, name: 'Mary Jane') } let(:group) { create(:group) } @@ -30,7 +32,7 @@ feature 'Groups members list', feature: true do expect(second_row).to be_blank end - it 'updates user to owner level', :js do + scenario 'update user to owner level', :js do group.add_owner(user1) group.add_developer(user2) @@ -38,13 +40,52 @@ feature 'Groups members list', feature: true do page.within(second_row) do click_button('Developer') - click_link('Owner') expect(page).to have_button('Owner') end end + scenario 'add user to group', :js do + group.add_owner(user1) + + visit group_group_members_path(group) + + add_user(user2.id, 'Reporter') + + page.within(second_row) do + expect(page).to have_content(user2.name) + expect(page).to have_button('Reporter') + end + end + + scenario 'add yourself to group when already an owner', :js do + group.add_owner(user1) + + visit group_group_members_path(group) + + add_user(user1.id, 'Reporter') + + page.within(first_row) do + expect(page).to have_content(user1.name) + expect(page).to have_content('Owner') + end + end + + scenario 'invite user to group', :js do + group.add_owner(user1) + + visit group_group_members_path(group) + + add_user('test@example.com', 'Reporter') + + page.within(second_row) do + expect(page).to have_content('test@example.com') + expect(page).to have_content('Invited') + expect(page).to have_button('Reporter') + end + end + def first_row page.all('ul.content-list > li')[0] end @@ -52,4 +93,13 @@ feature 'Groups members list', feature: true do def second_row page.all('ul.content-list > li')[1] end + + def add_user(id, role) + page.within ".users-group-form" do + select2(id, from: "#user_ids", multiple: true) + select(role, from: "access_level") + end + + click_button "Add to group" + end end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb new file mode 100644 index 00000000000..daa2c6afd63 --- /dev/null +++ b/spec/features/groups/milestone_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +feature 'Group milestones', :feature, :js do + let(:group) { create(:group) } + let!(:project) { create(:project_empty_repo, group: group) } + let(:user) { create(:group_member, :master, user: create(:user), group: group ).user } + + before do + Timecop.freeze + + login_as(user) + end + + after do + Timecop.return + end + + context 'create a milestone' do + before do + visit new_group_milestone_path(group) + end + + it 'creates milestone with start date' do + fill_in 'Title', with: 'testing' + find('#milestone_start_date').click + + page.within(find('.pika-single')) do + click_button '1' + end + + click_button 'Create milestone' + + expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y')) + end + end +end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 7b9d4534ada..85585587fb1 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -120,6 +120,20 @@ feature 'Issue Sidebar', feature: true do end end + context 'as a allowed mobile user', js: true do + before do + project.team << [user, :developer] + resize_screen_xs + visit_issue(project, issue) + end + + context 'mobile sidebar' do + it 'collapses the sidebar for small screens' do + expect(page).not_to have_css('aside.right-sidebar.right-sidebar-collapsed') + end + end + end + context 'as a guest' do before do project.team << [user, :guest] diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index f5cfe2d666e..378f6de1a78 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -1,17 +1,15 @@ require 'spec_helper' -feature 'Issue notes polling' do - let!(:project) { create(:project, :public) } - let!(:issue) { create(:issue, project: project) } +feature 'Issue notes polling', :feature, :js do + let(:project) { create(:empty_project, :public) } + let(:issue) { create(:issue, project: project) } - background do + before do visit namespace_project_issue_path(project.namespace, project, issue) end - scenario 'Another user adds a comment to an issue', js: true do - note = create(:note, noteable: issue, project: project, - note: 'Looks good!') - + it 'should display the new comment' do + note = create(:note, noteable: issue, project: project, note: 'Looks good!') page.execute_script('notes.refresh();') expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index a62c5435748..4e128cd4a7d 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -141,6 +141,27 @@ describe 'Merge request', :feature, :js do end end + context 'view merge request with MWPS enabled but automatically merge fails' do + before do + merge_request.update( + merge_when_pipeline_succeeds: true, + merge_user: merge_request.author, + merge_error: 'Something went wrong' + ) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows information about the merge error' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + + page.within('.mr-widget-body') do + expect(page).to have_content('Something went wrong') + end + end + end + context 'merge error' do before do allow_any_instance_of(Repository).to receive(:merge).and_return(false) diff --git a/spec/features/projects/blobs/user_create_spec.rb b/spec/features/projects/blobs/user_create_spec.rb index fa1a753afcb..6ea149956fe 100644 --- a/spec/features/projects/blobs/user_create_spec.rb +++ b/spec/features/projects/blobs/user_create_spec.rb @@ -77,7 +77,7 @@ feature 'New blob creation', feature: true, js: true do project, user, start_branch: 'master', - target_branch: 'master', + branch_name: 'master', commit_message: 'Create file', file_path: 'feature.rb', file_content: content @@ -87,7 +87,7 @@ feature 'New blob creation', feature: true, js: true do end scenario 'shows error message' do - expect(page).to have_content('Your changes could not be committed because a file with the same name already exists') + expect(page).to have_content('A file with this name already exists') expect(page).to have_content('New file') expect(page).to have_content('NextFeature') end diff --git a/spec/features/projects/files/creating_a_file_spec.rb b/spec/features/projects/files/creating_a_file_spec.rb index 5d7bd3dc4ce..de6905f2b58 100644 --- a/spec/features/projects/files/creating_a_file_spec.rb +++ b/spec/features/projects/files/creating_a_file_spec.rb @@ -29,16 +29,16 @@ feature 'User wants to create a file', feature: true do scenario 'directory name contains Chinese characters' do submit_new_file(file_name: 'ä¸æ–‡/测试.md') - expect(page).to have_content 'The file has been successfully created.' + expect(page).to have_content 'The file has been successfully created' end scenario 'file name contains invalid characters' do submit_new_file(file_name: '\\') - expect(page).to have_content 'Your changes could not be committed, because the file name can contain only' + expect(page).to have_content 'Path can contain only' end scenario 'file name contains directory traversal' do submit_new_file(file_name: '../README.md') - expect(page).to have_content 'Your changes could not be committed, because the file name cannot include directory traversal.' + expect(page).to have_content 'Path cannot include directory traversal' end end diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb index 3e544316f28..4da34108b46 100644 --- a/spec/features/projects/files/editing_a_file_spec.rb +++ b/spec/features/projects/files/editing_a_file_spec.rb @@ -8,7 +8,7 @@ feature 'User wants to edit a file', feature: true do let(:commit_params) do { start_branch: project.default_branch, - target_branch: project.default_branch, + branch_name: project.default_branch, commit_message: "Committing First Update", file_path: ".gitignore", file_content: "First Update", diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb new file mode 100644 index 00000000000..deea34214fb --- /dev/null +++ b/spec/features/projects/members/list_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +feature 'Project members list', feature: true do + include Select2Helper + + let(:user1) { create(:user, name: 'John Doe') } + let(:user2) { create(:user, name: 'Mary Jane') } + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + + background do + login_as(user1) + group.add_owner(user1) + end + + scenario 'show members from project and group' do + project.add_developer(user2) + + visit_members_page + + expect(first_row.text).to include(user1.name) + expect(second_row.text).to include(user2.name) + end + + scenario 'show user once if member of both group and project' do + project.add_developer(user1) + + visit_members_page + + expect(first_row.text).to include(user1.name) + expect(second_row).to be_blank + end + + scenario 'update user acess level', :js do + project.add_developer(user2) + + visit_members_page + + page.within(second_row) do + click_button('Developer') + click_link('Reporter') + + expect(page).to have_button('Reporter') + end + end + + scenario 'add user to project', :js do + visit_members_page + + add_user(user2.id, 'Reporter') + + page.within(second_row) do + expect(page).to have_content(user2.name) + expect(page).to have_button('Reporter') + end + end + + scenario 'invite user to project', :js do + visit_members_page + + add_user('test@example.com', 'Reporter') + + page.within(second_row) do + expect(page).to have_content('test@example.com') + expect(page).to have_content('Invited') + expect(page).to have_button('Reporter') + end + end + + def first_row + page.all('ul.content-list > li')[0] + end + + def second_row + page.all('ul.content-list > li')[1] + end + + def add_user(id, role) + page.within ".users-project-form" do + select2(id, from: "#user_ids", multiple: true) + select(role, from: "access_level") + end + + click_button "Add to project" + end + + def visit_members_page + visit namespace_project_settings_members_path(project.namespace, project) + end +end diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb index ce5c5f21167..34c6a10950f 100644 --- a/spec/features/projects/view_on_env_spec.rb +++ b/spec/features/projects/view_on_env_spec.rb @@ -25,7 +25,7 @@ describe 'View on environment', js: true do project, user, start_branch: branch_name, - target_branch: branch_name, + branch_name: branch_name, commit_message: "Add .gitlab/route-map.yml", file_path: '.gitlab/route-map.yml', file_content: route_map @@ -36,7 +36,7 @@ describe 'View on environment', js: true do project, user, start_branch: branch_name, - target_branch: branch_name, + branch_name: branch_name, commit_message: "Update feature", file_path: file_path, file_content: "# Noop" diff --git a/spec/javascripts/blob/sketch/index_spec.js b/spec/javascripts/blob/sketch/index_spec.js index 0e4431548c4..79f40559817 100644 --- a/spec/javascripts/blob/sketch/index_spec.js +++ b/spec/javascripts/blob/sketch/index_spec.js @@ -1,4 +1,4 @@ -/* eslint-disable no-new */ +/* eslint-disable no-new, promise/catch-or-return */ import JSZip from 'jszip'; import SketchLoader from '~/blob/sketch'; diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js index 35239e4fb8e..fd153a49fcd 100644 --- a/spec/javascripts/droplab/constants_spec.js +++ b/spec/javascripts/droplab/constants_spec.js @@ -26,4 +26,10 @@ describe('constants', function () { expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); }); }); + + describe('IGNORE_CLASS', function () { + it('should be `droplab-item-ignore`', function() { + expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); + }); + }); }); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 802e2435672..7516b301917 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -2,7 +2,7 @@ import DropDown from '~/droplab/drop_down'; import utils from '~/droplab/utils'; -import { SELECTED_CLASS } from '~/droplab/constants'; +import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants'; describe('DropDown', function () { describe('class constructor', function () { @@ -128,9 +128,10 @@ describe('DropDown', function () { describe('clickEvent', function () { beforeEach(function () { + this.classList = jasmine.createSpyObj('classList', ['contains']); this.list = { dispatchEvent: () => {} }; this.dropdown = { hide: () => {}, list: this.list, addSelectedClass: () => {} }; - this.event = { preventDefault: () => {}, target: {} }; + this.event = { preventDefault: () => {}, target: { classList: this.classList } }; this.customEvent = {}; this.closestElement = {}; @@ -140,6 +141,7 @@ describe('DropDown', function () { spyOn(this.event, 'preventDefault'); spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); spyOn(utils, 'closest').and.returnValues(this.closestElement, undefined); + this.classList.contains.and.returnValue(false); DropDown.prototype.clickEvent.call(this.dropdown, this.event); }); @@ -164,15 +166,35 @@ describe('DropDown', function () { expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object)); }); + it('should call .classList.contains checking for IGNORE_CLASS', function () { + expect(this.classList.contains).toHaveBeenCalledWith(IGNORE_CLASS); + }); + it('should call .dispatchEvent with the customEvent', function () { expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent); }); describe('if the target is a UL element', function () { beforeEach(function () { - this.event = { preventDefault: () => {}, target: { tagName: 'UL' } }; + this.event = { preventDefault: () => {}, target: { tagName: 'UL', classList: this.classList } }; + + spyOn(this.event, 'preventDefault'); + utils.closest.calls.reset(); + + DropDown.prototype.clickEvent.call(this.dropdown, this.event); + }); + + it('should return immediately', function () { + expect(utils.closest).not.toHaveBeenCalled(); + }); + }); + + describe('if the target has the IGNORE_CLASS class', function () { + beforeEach(function () { + this.event = { preventDefault: () => {}, target: { tagName: 'LI', classList: this.classList } }; spyOn(this.event, 'preventDefault'); + this.classList.contains.and.returnValue(true); utils.closest.calls.reset(); DropDown.prototype.clickEvent.call(this.dropdown, this.event); diff --git a/spec/javascripts/environments/environment_external_url_spec.js b/spec/javascripts/environments/environment_external_url_spec.js index 9af218a27ff..056d68a26e9 100644 --- a/spec/javascripts/environments/environment_external_url_spec.js +++ b/spec/javascripts/environments/environment_external_url_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import externalUrlComp from '~/environments/components/environment_external_url'; +import externalUrlComp from '~/environments/components/environment_external_url.vue'; describe('External URL Component', () => { let ExternalUrlComponent; diff --git a/spec/javascripts/environments/environment_monitoring_spec.js b/spec/javascripts/environments/environment_monitoring_spec.js index fc451cce641..0f3dba66230 100644 --- a/spec/javascripts/environments/environment_monitoring_spec.js +++ b/spec/javascripts/environments/environment_monitoring_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import monitoringComp from '~/environments/components/environment_monitoring'; +import monitoringComp from '~/environments/components/environment_monitoring.vue'; describe('Monitoring Component', () => { let MonitoringComponent; diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js index 7cb39d9df03..25397714a76 100644 --- a/spec/javascripts/environments/environment_rollback_spec.js +++ b/spec/javascripts/environments/environment_rollback_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import rollbackComp from '~/environments/components/environment_rollback'; +import rollbackComp from '~/environments/components/environment_rollback.vue'; describe('Rollback Component', () => { const retryURL = 'https://gitlab.com/retry'; diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js index 01055e3f255..942e4aaabd4 100644 --- a/spec/javascripts/environments/environment_stop_spec.js +++ b/spec/javascripts/environments/environment_stop_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import stopComp from '~/environments/components/environment_stop'; +import stopComp from '~/environments/components/environment_stop.vue'; describe('Stop Component', () => { let StopComponent; diff --git a/spec/javascripts/environments/environment_terminal_button_spec.js b/spec/javascripts/environments/environment_terminal_button_spec.js index be2289edc2b..858472af4b6 100644 --- a/spec/javascripts/environments/environment_terminal_button_spec.js +++ b/spec/javascripts/environments/environment_terminal_button_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import terminalComp from '~/environments/components/environment_terminal_button'; +import terminalComp from '~/environments/components/environment_terminal_button.vue'; describe('Stop Component', () => { let TerminalComponent; diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index 2a58fb3a7df..c255bf7c939 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,3 +1,5 @@ +/* eslint-disable promise/catch-or-return */ + import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; describe('RecentSearchesService', () => { diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 56aabc16382..a00efa10119 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -1,3 +1,5 @@ +/* eslint-disable promise/catch-or-return */ + require('~/lib/utils/common_utils'); (() => { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index d81a5bbb6a5..ca8ee04d955 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -72,5 +72,157 @@ require('~/lib/utils/text_utility'); expect(this.autoSizeSpy).toHaveBeenTriggered(); }); }); + + describe('renderNote', () => { + let notes; + let note; + let $notesList; + + beforeEach(() => { + note = { + discussion_html: null, + valid: true, + html: '<div></div>', + }; + $notesList = jasmine.createSpyObj('$notesList', ['find']); + + notes = jasmine.createSpyObj('notes', [ + 'refresh', + 'isNewNote', + 'collapseLongCommitList', + 'updateNotesCount', + ]); + notes.taskList = jasmine.createSpyObj('tasklist', ['init']); + notes.note_ids = []; + + spyOn(window, '$').and.returnValue($notesList); + spyOn(gl.utils, 'localTimeAgo'); + spyOn(Notes, 'animateAppendNote'); + notes.isNewNote.and.returnValue(true); + + Notes.prototype.renderNote.call(notes, note); + }); + + it('should query for the notes list', () => { + expect(window.$).toHaveBeenCalledWith('ul.main-notes-list'); + }); + + it('should call .animateAppendNote', () => { + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + }); + }); + + describe('renderDiscussionNote', () => { + let discussionContainer; + let note; + let notes; + let $form; + let row; + + beforeEach(() => { + note = { + html: '<li></li>', + discussion_html: '<div></div>', + discussion_id: 1, + discussion_resolvable: false, + diff_discussion_html: false, + }; + $form = jasmine.createSpyObj('$form', ['closest', 'find']); + row = jasmine.createSpyObj('row', ['prevAll', 'first', 'find']); + + notes = jasmine.createSpyObj('notes', [ + 'isNewNote', + 'isParallelView', + 'updateNotesCount', + ]); + notes.note_ids = []; + + spyOn(gl.utils, 'localTimeAgo'); + spyOn(Notes, 'animateAppendNote'); + notes.isNewNote.and.returnValue(true); + notes.isParallelView.and.returnValue(false); + row.prevAll.and.returnValue(row); + row.first.and.returnValue(row); + row.find.and.returnValue(row); + }); + + describe('Discussion root note', () => { + let $notesList; + let body; + + beforeEach(() => { + body = jasmine.createSpyObj('body', ['attr']); + discussionContainer = { length: 0 }; + + spyOn(window, '$').and.returnValues(discussionContainer, body, $notesList); + $form.closest.and.returnValues(row, $form); + $form.find.and.returnValues(discussionContainer); + body.attr.and.returnValue(''); + + Notes.prototype.renderDiscussionNote.call(notes, note, $form); + }); + + it('should query for the notes list', () => { + expect(window.$.calls.argsFor(2)).toEqual(['ul.main-notes-list']); + }); + + it('should call Notes.animateAppendNote', () => { + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $notesList); + }); + }); + + describe('Discussion sub note', () => { + beforeEach(() => { + discussionContainer = { length: 1 }; + + spyOn(window, '$').and.returnValues(discussionContainer); + $form.closest.and.returnValues(row); + + Notes.prototype.renderDiscussionNote.call(notes, note, $form); + }); + + it('should query foor the discussion container', () => { + expect(window.$).toHaveBeenCalledWith(`.notes[data-discussion-id="${note.discussion_id}"]`); + }); + + it('should call Notes.animateAppendNote', () => { + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer); + }); + }); + }); + + describe('animateAppendNote', () => { + let noteHTML; + let $note; + let $notesList; + + beforeEach(() => { + noteHTML = '<div></div>'; + $note = jasmine.createSpyObj('$note', ['addClass', 'renderGFM', 'removeClass']); + $notesList = jasmine.createSpyObj('$notesList', ['append']); + + spyOn(window, '$').and.returnValue($note); + spyOn(window, 'setTimeout').and.callThrough(); + $note.addClass.and.returnValue($note); + $note.renderGFM.and.returnValue($note); + + Notes.animateAppendNote(noteHTML, $notesList); + }); + + it('should init the note jquery object', () => { + expect(window.$).toHaveBeenCalledWith(noteHTML); + }); + + it('should call addClass', () => { + expect($note.addClass).toHaveBeenCalledWith('fade-in'); + }); + it('should call renderGFM', () => { + expect($note.renderGFM).toHaveBeenCalledWith(); + }); + + it('should append note to the notes list', () => { + expect($notesList.append).toHaveBeenCalledWith($note); + }); + }); }); }).call(window); diff --git a/spec/javascripts/vue_pipelines_index/async_button_spec.js b/spec/javascripts/pipelines/async_button_spec.js index 28c9c7ab282..28c9c7ab282 100644 --- a/spec/javascripts/vue_pipelines_index/async_button_spec.js +++ b/spec/javascripts/pipelines/async_button_spec.js diff --git a/spec/javascripts/vue_pipelines_index/empty_state_spec.js b/spec/javascripts/pipelines/empty_state_spec.js index bb47a28d9fe..bb47a28d9fe 100644 --- a/spec/javascripts/vue_pipelines_index/empty_state_spec.js +++ b/spec/javascripts/pipelines/empty_state_spec.js diff --git a/spec/javascripts/vue_pipelines_index/error_state_spec.js b/spec/javascripts/pipelines/error_state_spec.js index f667d351f72..f667d351f72 100644 --- a/spec/javascripts/vue_pipelines_index/error_state_spec.js +++ b/spec/javascripts/pipelines/error_state_spec.js diff --git a/spec/javascripts/vue_pipelines_index/mock_data.js b/spec/javascripts/pipelines/mock_data.js index 2365a662b9f..2365a662b9f 100644 --- a/spec/javascripts/vue_pipelines_index/mock_data.js +++ b/spec/javascripts/pipelines/mock_data.js diff --git a/spec/javascripts/vue_pipelines_index/nav_controls_spec.js b/spec/javascripts/pipelines/nav_controls_spec.js index 601eebce38a..601eebce38a 100644 --- a/spec/javascripts/vue_pipelines_index/nav_controls_spec.js +++ b/spec/javascripts/pipelines/nav_controls_spec.js diff --git a/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 53931d67ad7..53931d67ad7 100644 --- a/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js diff --git a/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js b/spec/javascripts/pipelines/pipelines_actions_spec.js index c89dacbcd93..c89dacbcd93 100644 --- a/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js +++ b/spec/javascripts/pipelines/pipelines_actions_spec.js diff --git a/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js b/spec/javascripts/pipelines/pipelines_artifacts_spec.js index 9724b63d957..9724b63d957 100644 --- a/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js +++ b/spec/javascripts/pipelines/pipelines_artifacts_spec.js diff --git a/spec/javascripts/vue_pipelines_index/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index e9c05f74ce6..e9c05f74ce6 100644 --- a/spec/javascripts/vue_pipelines_index/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js diff --git a/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js b/spec/javascripts/pipelines/pipelines_store_spec.js index 10ff0c6bb84..10ff0c6bb84 100644 --- a/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js +++ b/spec/javascripts/pipelines/pipelines_store_spec.js diff --git a/spec/javascripts/vue_pipelines_index/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js index 66b57a82363..66b57a82363 100644 --- a/spec/javascripts/vue_pipelines_index/stage_spec.js +++ b/spec/javascripts/pipelines/stage_spec.js diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb index 5cb98163746..600f3c123ed 100644 --- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb +++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb @@ -5,6 +5,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do include FilterSpecHelper let(:user) { create(:user) } + let(:context) { { current_user: user, issuable_state_filter_enabled: true } } def create_link(text, data) link_to(text, '', class: 'gfm has-tooltip', data: data) @@ -20,7 +21,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores non-issuable links' do project = create(:empty_project, :public) link = create_link('text', project: project, reference_type: 'issue') - doc = filter(link, current_user: user) + doc = filter(link, context) expect(doc.css('a').last.text).to eq('text') end @@ -28,89 +29,142 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do it 'ignores issuable links with empty content' do issue = create(:issue, :closed) link = create_link('', issue: issue.id, reference_type: 'issue') - doc = filter(link, current_user: user) + doc = filter(link, context) expect(doc.css('a').last.text).to eq('') end - it 'adds text with standard formatting' do + it 'ignores issuable links with custom anchor' do issue = create(:issue, :closed) + link = create_link('something', issue: issue.id, reference_type: 'issue') + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq('something') + end + + it 'ignores issuable links to specific comments' do + issue = create(:issue, :closed) + link = create_link("#{issue.to_reference} (comment 1)", issue: issue.id, reference_type: 'issue') + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq("#{issue.to_reference} (comment 1)") + end + + it 'ignores merge request links to diffs tab' do + merge_request = create(:merge_request, :closed) link = create_link( - 'something <strong>else</strong>'.html_safe, - issue: issue.id, - reference_type: 'issue' + "#{merge_request.to_reference} (diffs)", + merge_request: merge_request.id, + reference_type: 'merge_request' ) - doc = filter(link, current_user: user) + doc = filter(link, context) - expect(doc.css('a').last.inner_html). - to eq('something <strong>else</strong> [closed]') + expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (diffs)") + end + + it 'handles cross project references' do + issue = create(:issue, :closed) + project = create(:empty_project) + link = create_link(issue.to_reference(project), issue: issue.id, reference_type: 'issue') + doc = filter(link, context.merge(project: project)) + + expect(doc.css('a').last.text).to eq("#{issue.to_reference(project)} (closed)") + end + + it 'does not append state when filter is not enabled' do + issue = create(:issue, :closed) + link = create_link('text', issue: issue.id, reference_type: 'issue') + context = { current_user: user } + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq('text') end context 'for issue references' do it 'ignores open issue references' do issue = create(:issue) - link = create_link('text', issue: issue.id, reference_type: 'issue') - doc = filter(link, current_user: user) + link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue') + doc = filter(link, context) - expect(doc.css('a').last.text).to eq('text') + expect(doc.css('a').last.text).to eq(issue.to_reference) end it 'ignores reopened issue references' do - reopened_issue = create(:issue, :reopened) - link = create_link('text', issue: reopened_issue.id, reference_type: 'issue') - doc = filter(link, current_user: user) + issue = create(:issue, :reopened) + link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue') + doc = filter(link, context) - expect(doc.css('a').last.text).to eq('text') + expect(doc.css('a').last.text).to eq(issue.to_reference) end - it 'appends [closed] to closed issue references' do - closed_issue = create(:issue, :closed) - link = create_link('text', issue: closed_issue.id, reference_type: 'issue') - doc = filter(link, current_user: user) + it 'appends state to closed issue references' do + issue = create(:issue, :closed) + link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue') + doc = filter(link, context) - expect(doc.css('a').last.text).to eq('text [closed]') + expect(doc.css('a').last.text).to eq("#{issue.to_reference} (closed)") end end context 'for merge request references' do it 'ignores open merge request references' do - mr = create(:merge_request) - link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') - doc = filter(link, current_user: user) - - expect(doc.css('a').last.text).to eq('text') + merge_request = create(:merge_request) + link = create_link( + merge_request.to_reference, + merge_request: merge_request.id, + reference_type: 'merge_request' + ) + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq(merge_request.to_reference) end it 'ignores reopened merge request references' do - mr = create(:merge_request, :reopened) - link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') - doc = filter(link, current_user: user) - - expect(doc.css('a').last.text).to eq('text') + merge_request = create(:merge_request, :reopened) + link = create_link( + merge_request.to_reference, + merge_request: merge_request.id, + reference_type: 'merge_request' + ) + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq(merge_request.to_reference) end it 'ignores locked merge request references' do - mr = create(:merge_request, :locked) - link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') - doc = filter(link, current_user: user) - - expect(doc.css('a').last.text).to eq('text') + merge_request = create(:merge_request, :locked) + link = create_link( + merge_request.to_reference, + merge_request: merge_request.id, + reference_type: 'merge_request' + ) + doc = filter(link, context) + + expect(doc.css('a').last.text).to eq(merge_request.to_reference) end - it 'appends [closed] to closed merge request references' do - mr = create(:merge_request, :closed) - link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') - doc = filter(link, current_user: user) + it 'appends state to closed merge request references' do + merge_request = create(:merge_request, :closed) + link = create_link( + merge_request.to_reference, + merge_request: merge_request.id, + reference_type: 'merge_request' + ) + doc = filter(link, context) - expect(doc.css('a').last.text).to eq('text [closed]') + expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (closed)") end - it 'appends [merged] to merged merge request references' do - mr = create(:merge_request, :merged) - link = create_link('text', merge_request: mr.id, reference_type: 'merge_request') - doc = filter(link, current_user: user) + it 'appends state to merged merge request references' do + merge_request = create(:merge_request, :merged) + link = create_link( + merge_request.to_reference, + merge_request: merge_request.id, + reference_type: 'merge_request' + ) + doc = filter(link, context) - expect(doc.css('a').last.text).to eq('text [merged]') + expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (merged)") end end end diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index f85a5dcbd8b..9b8ecb201f3 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -5,7 +5,7 @@ describe Banzai::Filter::PlantumlFilter, lib: true do it 'should replace plantuml pre tag with img tag' do stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") - input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>' + input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>' doc = filter(input) @@ -14,8 +14,8 @@ describe Banzai::Filter::PlantumlFilter, lib: true do it 'should not replace plantuml pre tag with img tag if disabled' do stub_application_setting(plantuml_enabled: false) - input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>' - output = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre></pre></pre>' + input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' + output = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' doc = filter(input) expect(doc.to_s).to eq output @@ -23,7 +23,7 @@ describe Banzai::Filter::PlantumlFilter, lib: true do it 'should not replace plantuml pre tag with img tag if url is invalid' do stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") - input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>' + input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> PlantUML Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' doc = filter(input) diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index 994995b57b8..c166f83664a 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -100,7 +100,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do project, current_user, start_branch: branch_name, - target_branch: branch_name, + branch_name: branch_name, commit_message: "Create file", file_path: file_name, file_content: content @@ -113,7 +113,7 @@ describe Gitlab::Diff::PositionTracer, lib: true do project, current_user, start_branch: branch_name, - target_branch: branch_name, + branch_name: branch_name, commit_message: "Update file", file_path: file_name, file_content: content @@ -122,11 +122,11 @@ describe Gitlab::Diff::PositionTracer, lib: true do end def delete_file(branch_name, file_name) - Files::DestroyService.new( + Files::DeleteService.new( project, current_user, start_branch: branch_name, - target_branch: branch_name, + branch_name: branch_name, commit_message: "Delete file", file_path: file_name ).execute diff --git a/spec/lib/gitlab/git/index_spec.rb b/spec/lib/gitlab/git/index_spec.rb index 07d71f6777d..21b71654251 100644 --- a/spec/lib/gitlab/git/index_spec.rb +++ b/spec/lib/gitlab/git/index_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::Git::Index, seed_helper: true do end it 'raises an error' do - expect { index.create(options) }.to raise_error('Filename already exists') + expect { index.create(options) }.to raise_error('A file with this name already exists') end end @@ -89,7 +89,7 @@ describe Gitlab::Git::Index, seed_helper: true do end it 'raises an error' do - expect { index.create_dir(options) }.to raise_error('Directory already exists as a file') + expect { index.create_dir(options) }.to raise_error('A file with this name already exists') end end @@ -99,7 +99,7 @@ describe Gitlab::Git::Index, seed_helper: true do end it 'raises an error' do - expect { index.create_dir(options) }.to raise_error('Directory already exists') + expect { index.create_dir(options) }.to raise_error('A directory with this name already exists') end end end @@ -118,7 +118,7 @@ describe Gitlab::Git::Index, seed_helper: true do end it 'raises an error' do - expect { index.update(options) }.to raise_error("File doesn't exist") + expect { index.update(options) }.to raise_error("A file with this name doesn't exist") end end @@ -156,7 +156,15 @@ describe Gitlab::Git::Index, seed_helper: true do it 'raises an error' do options[:previous_path] = 'documents/story.txt' - expect { index.move(options) }.to raise_error("File doesn't exist") + expect { index.move(options) }.to raise_error("A file with this name doesn't exist") + end + end + + context 'when a file at the new path already exists' do + it 'raises an error' do + options[:file_path] = 'CHANGELOG' + + expect { index.move(options) }.to raise_error("A file with this name already exists") end end @@ -203,7 +211,7 @@ describe Gitlab::Git::Index, seed_helper: true do end it 'raises an error' do - expect { index.delete(options) }.to raise_error("File doesn't exist") + expect { index.delete(options) }.to raise_error("A file with this name doesn't exist") end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 703b41f95ac..d8b72615fab 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -211,7 +211,7 @@ describe Gitlab::GitAccess, lib: true do target_branch = project.repository.lookup('feature') source_branch = project.repository.create_file( user, - 'John Doe', + 'filename', 'This is the file content', message: 'This is a good commit message', branch_name: unprotected_branch) diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index ba45e2d758c..127cd8c78d8 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -32,12 +32,6 @@ describe Gitlab::Regex, lib: true do it { is_expected.to match('foo@bar') } end - describe '.file_path_regex' do - subject { described_class.file_path_regex } - - it { is_expected.to match('foo@/bar') } - end - describe '.environment_slug_regex' do subject { described_class.environment_slug_regex } diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb new file mode 100644 index 00000000000..7f21288cf88 --- /dev/null +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Gitlab::UsageData do + let!(:project) { create(:empty_project) } + let!(:project2) { create(:empty_project) } + let!(:board) { create(:board, project: project) } + + describe '#data' do + subject { Gitlab::UsageData.data } + + it "gathers usage data" do + expect(subject.keys).to match_array(%i( + active_user_count + counts + recorded_at + mattermost_enabled + edition + version + uuid + )) + end + + it "gathers usage counts" do + count_data = subject[:counts] + + expect(count_data[:boards]).to eq(1) + expect(count_data[:projects]).to eq(2) + + expect(count_data.keys).to match_array(%i( + boards + ci_builds + ci_pipelines + ci_runners + ci_triggers + deploy_keys + deployments + environments + groups + issues + keys + labels + lfs_objects + merge_requests + milestones + notes + projects + projects_prometheus_active + pages_domains + protected_branches + releases + services + snippets + todos + uploads + web_hooks + )) + end + end + + describe '#license_usage_data' do + subject { Gitlab::UsageData.license_usage_data } + + it "gathers license data" do + expect(subject[:uuid]).to eq(current_application_settings.uuid) + expect(subject[:version]).to eq(Gitlab::VERSION) + expect(subject[:active_user_count]).to eq(User.active.count) + expect(subject[:recorded_at]).to be_a(Time) + end + end +end diff --git a/spec/lib/gitlab/user_activities_spec.rb b/spec/lib/gitlab/user_activities_spec.rb new file mode 100644 index 00000000000..187d88c8c58 --- /dev/null +++ b/spec/lib/gitlab/user_activities_spec.rb @@ -0,0 +1,127 @@ +require 'spec_helper' + +describe Gitlab::UserActivities, :redis, lib: true do + let(:now) { Time.now } + + describe '.record' do + context 'with no time given' do + it 'uses Time.now and records an activity in Redis' do + Timecop.freeze do + now # eager-load now + described_class.record(42) + end + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]]) + end + end + end + + context 'with a time given' do + it 'uses the given time and records an activity in Redis' do + described_class.record(42, now) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]]) + end + end + end + end + + describe '.delete' do + context 'with a single key' do + context 'and key exists' do + it 'removes the pair from Redis' do + described_class.record(42, now) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]]) + end + + subject.delete(42) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []]) + end + end + end + + context 'and key does not exist' do + it 'removes the pair from Redis' do + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []]) + end + + subject.delete(42) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []]) + end + end + end + end + + context 'with multiple keys' do + context 'and all keys exist' do + it 'removes the pair from Redis' do + described_class.record(41, now) + described_class.record(42, now) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['41', now.to_i.to_s], ['42', now.to_i.to_s]]]) + end + + subject.delete(41, 42) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []]) + end + end + end + + context 'and some keys does not exist' do + it 'removes the existing pair from Redis' do + described_class.record(42, now) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', [['42', now.to_i.to_s]]]) + end + + subject.delete(41, 42) + + Gitlab::Redis.with do |redis| + expect(redis.hscan(described_class::KEY, 0)).to eq(['0', []]) + end + end + end + end + end + + describe 'Enumerable' do + before do + described_class.record(40, now) + described_class.record(41, now) + described_class.record(42, now) + end + + it 'allows to read the activities sequentially' do + expected = { '40' => now.to_i.to_s, '41' => now.to_i.to_s, '42' => now.to_i.to_s } + + actual = described_class.new.each_with_object({}) do |(key, time), actual| + actual[key] = time + end + + expect(actual).to eq(expected) + end + + context 'with many records' do + before do + 1_000.times { |i| described_class.record(i, now) } + end + + it 'is possible to loop through all the records' do + expect(described_class.new.count).to eq(1_000) + end + end + end +end diff --git a/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb new file mode 100644 index 00000000000..1db9bc002ae --- /dev/null +++ b/spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb @@ -0,0 +1,49 @@ +# encoding: utf-8 + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170324160416_migrate_user_activities_to_users_last_activity_on.rb') + +describe MigrateUserActivitiesToUsersLastActivityOn, :redis do + let(:migration) { described_class.new } + let!(:user_active_1) { create(:user) } + let!(:user_active_2) { create(:user) } + + def record_activity(user, time) + Gitlab::Redis.with do |redis| + redis.zadd(described_class::USER_ACTIVITY_SET_KEY, time.to_i, user.username) + end + end + + around do |example| + Timecop.freeze { example.run } + end + + before do + record_activity(user_active_1, described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 2.months) + record_activity(user_active_2, described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 3.months) + mute_stdout { migration.up } + end + + describe '#up' do + it 'fills last_activity_on from the legacy Redis Sorted Set' do + expect(user_active_1.reload.last_activity_on).to eq((described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 2.months).to_date) + expect(user_active_2.reload.last_activity_on).to eq((described_class::TIME_WHEN_ACTIVITY_SET_WAS_INTRODUCED + 3.months).to_date) + end + end + + describe '#down' do + it 'sets last_activity_on to NULL for all users' do + mute_stdout { migration.down } + + expect(user_active_1.reload.last_activity_on).to be_nil + expect(user_active_2.reload.last_activity_on).to be_nil + end + end + + def mute_stdout + orig_stdout = $stdout + $stdout = StringIO.new + yield + $stdout = orig_stdout + end +end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index d057c9cf6e9..11befd4edfe 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -361,7 +361,10 @@ describe Issue, models: true do it 'updates when assignees change' do user1 = create(:user) user2 = create(:user) - issue = create(:issue, assignee: user1) + project = create(:empty_project) + issue = create(:issue, assignee: user1, project: project) + project.add_developer(user1) + project.add_developer(user2) expect(user1.assigned_open_issues_count).to eq(1) expect(user2.assigned_open_issues_count).to eq(0) diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 90b3a2ba42d..415d3e7b200 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -820,15 +820,17 @@ describe MergeRequest, models: true do user1 = create(:user) user2 = create(:user) mr = create(:merge_request, assignee: user1) + mr.project.add_developer(user1) + mr.project.add_developer(user2) - expect(user1.assigned_open_merge_request_count).to eq(1) - expect(user2.assigned_open_merge_request_count).to eq(0) + expect(user1.assigned_open_merge_requests_count).to eq(1) + expect(user2.assigned_open_merge_requests_count).to eq(0) mr.assignee = user2 mr.save - expect(user1.assigned_open_merge_request_count).to eq(0) - expect(user2.assigned_open_merge_request_count).to eq(1) + expect(user1.assigned_open_merge_requests_count).to eq(0) + expect(user2.assigned_open_merge_requests_count).to eq(1) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6af5ef1018c..0a2860f2505 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -24,9 +24,7 @@ describe User, models: true do it { is_expected.to have_many(:recent_events).class_name('Event') } it { is_expected.to have_many(:issues).dependent(:restrict_with_exception) } it { is_expected.to have_many(:notes).dependent(:destroy) } - it { is_expected.to have_many(:assigned_issues).dependent(:nullify) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } - it { is_expected.to have_many(:assigned_merge_requests).dependent(:nullify) } it { is_expected.to have_many(:identities).dependent(:destroy) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index a10d876ffad..42dbab586cd 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -599,8 +599,7 @@ describe API::Commits, api: true do post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'markdown' expect(response).to have_http_status(400) - expect(json_response['message']).to eq('Sorry, we cannot cherry-pick this commit automatically. - A cherry-pick may have already been performed with this commit, or a more recent commit may have updated some of its content.') + expect(json_response['message']).to include('Sorry, we cannot cherry-pick this commit automatically.') end it 'returns 400 if you are not allowed to push to the target branch' do diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 8012530f139..6db2faed76b 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -205,7 +205,7 @@ describe API::Files, api: true do it "returns a 400 if editor fails to create file" do allow_any_instance_of(Repository).to receive(:create_file). - and_return(false) + and_raise(Repository::CommitError, 'Cannot create file') post api(route("any%2Etxt"), user), valid_params @@ -299,8 +299,8 @@ describe API::Files, api: true do expect(response).to have_http_status(400) end - it "returns a 400 if fails to create file" do - allow_any_instance_of(Repository).to receive(:delete_file).and_return(false) + it "returns a 400 if fails to delete file" do + allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Repository::CommitError, 'Cannot delete file') delete api(route(file_path), user), valid_params diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 4be67df5a00..3d6010ede73 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -147,10 +147,15 @@ describe API::Internal, api: true do end end - describe "POST /internal/allowed" do + describe "POST /internal/allowed", :redis do context "access granted" do before do project.team << [user, :developer] + Timecop.freeze + end + + after do + Timecop.return end context 'with env passed as a JSON' do @@ -176,6 +181,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(user).not_to have_an_activity_record end end @@ -186,6 +192,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(user).to have_an_activity_record end end @@ -196,6 +203,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(user).to have_an_activity_record end end @@ -206,6 +214,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(user).not_to have_an_activity_record end context 'project as /namespace/project' do @@ -241,6 +250,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_falsey + expect(user).not_to have_an_activity_record end end @@ -250,6 +260,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_falsey + expect(user).not_to have_an_activity_record end end end @@ -267,6 +278,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_falsey + expect(user).not_to have_an_activity_record end end @@ -276,6 +288,7 @@ describe API::Internal, api: true do expect(response).to have_http_status(200) expect(json_response["status"]).to be_falsey + expect(user).not_to have_an_activity_record end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index f793c0db2f3..165ab389917 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1,12 +1,12 @@ require 'spec_helper' -describe API::Users, api: true do +describe API::Users, api: true do include ApiHelpers - let(:user) { create(:user) } + let(:user) { create(:user) } let(:admin) { create(:admin) } - let(:key) { create(:key, user: user) } - let(:email) { create(:email, user: user) } + let(:key) { create(:key, user: user) } + let(:email) { create(:email, user: user) } let(:omniauth_user) { create(:omniauth_user) } let(:ldap_user) { create(:omniauth_user, provider: 'ldapmain') } let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') } @@ -72,6 +72,12 @@ describe API::Users, api: true do expect(json_response).to be_an Array expect(json_response.first['username']).to eq(omniauth_user.username) end + + it "returns a 403 when non-admin user searches by external UID" do + get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", user) + + expect(response).to have_http_status(403) + end end context "when admin" do @@ -100,6 +106,27 @@ describe API::Users, api: true do expect(json_response).to be_an Array expect(json_response).to all(include('external' => true)) end + + it "returns one user by external UID" do + get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['username']).to eq(omniauth_user.username) + end + + it "returns 400 error if provider with no extern_uid" do + get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}", admin) + + expect(response).to have_http_status(400) + end + + it "returns 400 error if provider with no extern_uid" do + get api("/users?provider=#{omniauth_user.identities.first.provider}", admin) + + expect(response).to have_http_status(400) + end end end @@ -129,7 +156,7 @@ describe API::Users, api: true do end describe "POST /users" do - before{ admin } + before { admin } it "creates user" do expect do @@ -214,9 +241,9 @@ describe API::Users, api: true do it "does not create user with invalid email" do post api('/users', admin), - email: 'invalid email', - password: 'password', - name: 'test' + email: 'invalid email', + password: 'password', + name: 'test' expect(response).to have_http_status(400) end @@ -242,12 +269,12 @@ describe API::Users, api: true do it 'returns 400 error if user does not validate' do post api('/users', admin), - password: 'pass', - email: 'test@example.com', - username: 'test!', - name: 'test', - bio: 'g' * 256, - projects_limit: -1 + password: 'pass', + email: 'test@example.com', + username: 'test!', + name: 'test', + bio: 'g' * 256, + projects_limit: -1 expect(response).to have_http_status(400) expect(json_response['message']['password']). to eq(['is too short (minimum is 8 characters)']) @@ -267,19 +294,19 @@ describe API::Users, api: true do context 'with existing user' do before do post api('/users', admin), - email: 'test@example.com', - password: 'password', - username: 'test', - name: 'foo' + email: 'test@example.com', + password: 'password', + username: 'test', + name: 'foo' end it 'returns 409 conflict error if user with same email exists' do expect do post api('/users', admin), - name: 'foo', - email: 'test@example.com', - password: 'password', - username: 'foo' + name: 'foo', + email: 'test@example.com', + password: 'password', + username: 'foo' end.to change { User.count }.by(0) expect(response).to have_http_status(409) expect(json_response['message']).to eq('Email has already been taken') @@ -288,10 +315,10 @@ describe API::Users, api: true do it 'returns 409 conflict error if same username exists' do expect do post api('/users', admin), - name: 'foo', - email: 'foo@example.com', - password: 'password', - username: 'test' + name: 'foo', + email: 'foo@example.com', + password: 'password', + username: 'test' end.to change { User.count }.by(0) expect(response).to have_http_status(409) expect(json_response['message']).to eq('Username has already been taken') @@ -416,12 +443,12 @@ describe API::Users, api: true do it 'returns 400 error if user does not validate' do put api("/users/#{user.id}", admin), - password: 'pass', - email: 'test@example.com', - username: 'test!', - name: 'test', - bio: 'g' * 256, - projects_limit: -1 + password: 'pass', + email: 'test@example.com', + username: 'test!', + name: 'test', + bio: 'g' * 256, + projects_limit: -1 expect(response).to have_http_status(400) expect(json_response['message']['password']). to eq(['is too short (minimum is 8 characters)']) @@ -488,7 +515,7 @@ describe API::Users, api: true do key_attrs = attributes_for :key expect do post api("/users/#{user.id}/keys", admin), key_attrs - end.to change{ user.keys.count }.by(1) + end.to change { user.keys.count }.by(1) end it "returns 400 for invalid ID" do @@ -580,7 +607,7 @@ describe API::Users, api: true do email_attrs = attributes_for :email expect do post api("/users/#{user.id}/emails", admin), email_attrs - end.to change{ user.emails.count }.by(1) + end.to change { user.emails.count }.by(1) end it "returns a 400 for invalid ID" do @@ -842,7 +869,7 @@ describe API::Users, api: true do key_attrs = attributes_for :key expect do post api("/user/keys", user), key_attrs - end.to change{ user.keys.count }.by(1) + end.to change { user.keys.count }.by(1) expect(response).to have_http_status(201) end @@ -880,7 +907,7 @@ describe API::Users, api: true do delete api("/user/keys/#{key.id}", user) expect(response).to have_http_status(204) - end.to change{user.keys.count}.by(-1) + end.to change { user.keys.count}.by(-1) end it "returns 404 if key ID not found" do @@ -963,7 +990,7 @@ describe API::Users, api: true do email_attrs = attributes_for :email expect do post api("/user/emails", user), email_attrs - end.to change{ user.emails.count }.by(1) + end.to change { user.emails.count }.by(1) expect(response).to have_http_status(201) end @@ -989,7 +1016,7 @@ describe API::Users, api: true do delete api("/user/emails/#{email.id}", user) expect(response).to have_http_status(204) - end.to change{user.emails.count}.by(-1) + end.to change { user.emails.count}.by(-1) end it "returns 404 if email ID not found" do @@ -1158,6 +1185,49 @@ describe API::Users, api: true do end end + context "user activities", :redis do + let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) } + let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) } + + context 'last activity as normal user' do + it 'has no permission' do + get api("/user/activities", user) + + expect(response).to have_http_status(403) + end + end + + context 'as admin' do + it 'returns the activities from the last 6 months' do + get api("/user/activities", admin) + + expect(response).to include_pagination_headers + expect(json_response.size).to eq(1) + + activity = json_response.last + + expect(activity['username']).to eq(newly_active_user.username) + expect(activity['last_activity_on']).to eq(2.days.ago.to_date.to_s) + expect(activity['last_activity_at']).to eq(2.days.ago.to_date.to_s) + end + + context 'passing a :from parameter' do + it 'returns the activities from the given date' do + get api("/user/activities?from=2000-1-1", admin) + + expect(response).to include_pagination_headers + expect(json_response.size).to eq(2) + + activity = json_response.first + + expect(activity['username']).to eq(old_active_user.username) + expect(activity['last_activity_on']).to eq(Time.utc(2000, 1, 1).to_date.to_s) + expect(activity['last_activity_at']).to eq(Time.utc(2000, 1, 1).to_date.to_s) + end + end + end + end + describe 'GET /users/:user_id/impersonation_tokens' do let!(:active_personal_access_token) { create(:personal_access_token, user: user) } let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index adba3a787aa..0a28cb9bddb 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -485,8 +485,7 @@ describe API::V3::Commits, api: true do post v3_api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'markdown' expect(response).to have_http_status(400) - expect(json_response['message']).to eq('Sorry, we cannot cherry-pick this commit automatically. - A cherry-pick may have already been performed with this commit, or a more recent commit may have updated some of its content.') + expect(json_response['message']).to include('Sorry, we cannot cherry-pick this commit automatically.') end it 'returns 400 if you are not allowed to push to the target branch' do diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb index 349fd6b3415..c45e2028e1d 100644 --- a/spec/requests/api/v3/files_spec.rb +++ b/spec/requests/api/v3/files_spec.rb @@ -129,7 +129,7 @@ describe API::V3::Files, api: true do it "returns a 400 if editor fails to create file" do allow_any_instance_of(Repository).to receive(:create_file). - and_return(false) + and_raise(Repository::CommitError, 'Cannot create file') post v3_api("/projects/#{project.id}/repository/files", user), valid_params @@ -229,8 +229,8 @@ describe API::V3::Files, api: true do expect(response).to have_http_status(400) end - it "returns a 400 if fails to create file" do - allow_any_instance_of(Repository).to receive(:delete_file).and_return(false) + it "returns a 400 if fails to delete file" do + allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Repository::CommitError, 'Cannot delete file') delete v3_api("/projects/#{project.id}/repository/files", user), valid_params diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 006d6a6af1c..316742ff076 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -3,6 +3,7 @@ require "spec_helper" describe 'Git HTTP requests', lib: true do include GitHttpHelpers include WorkhorseHelpers + include UserActivitiesHelpers it "gives WWW-Authenticate hints" do clone_get('doesnt/exist.git') @@ -255,6 +256,14 @@ describe 'Git HTTP requests', lib: true do expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) end end + + it 'updates the user last activity', :redis do + expect(user_activity(user)).to be_nil + + download(path, env) do |response| + expect(user_activity(user)).to be_present + end + end end context "when an oauth token is provided" do diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb new file mode 100644 index 00000000000..1e99442fdcb --- /dev/null +++ b/spec/services/cohorts_service_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe CohortsService do + describe '#execute' do + def month_start(months_ago) + months_ago.months.ago.beginning_of_month.to_date + end + + # In the interests of speed and clarity, this example has minimal data. + it 'returns a list of user cohorts' do + 6.times do |months_ago| + months_ago_time = (months_ago * 2).months.ago + + create(:user, created_at: months_ago_time, last_activity_on: Time.now) + create(:user, created_at: months_ago_time, last_activity_on: months_ago_time) + end + + create(:user) # this user is inactive and belongs to the current month + + expected_cohorts = [ + { + registration_month: month_start(11), + activity_months: Array.new(12) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(10), + activity_months: [{ total: 2, percentage: 100 }] + Array.new(10) { { total: 1, percentage: 50 } }, + total: 2, + inactive: 0 + }, + { + registration_month: month_start(9), + activity_months: Array.new(10) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(8), + activity_months: [{ total: 2, percentage: 100 }] + Array.new(8) { { total: 1, percentage: 50 } }, + total: 2, + inactive: 0 + }, + { + registration_month: month_start(7), + activity_months: Array.new(8) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(6), + activity_months: [{ total: 2, percentage: 100 }] + Array.new(6) { { total: 1, percentage: 50 } }, + total: 2, + inactive: 0 + }, + { + registration_month: month_start(5), + activity_months: Array.new(6) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(4), + activity_months: [{ total: 2, percentage: 100 }] + Array.new(4) { { total: 1, percentage: 50 } }, + total: 2, + inactive: 0 + }, + { + registration_month: month_start(3), + activity_months: Array.new(4) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(2), + activity_months: [{ total: 2, percentage: 100 }] + Array.new(2) { { total: 1, percentage: 50 } }, + total: 2, + inactive: 0 + }, + { + registration_month: month_start(1), + activity_months: Array.new(2) { { total: 0, percentage: 0 } }, + total: 0, + inactive: 0 + }, + { + registration_month: month_start(0), + activity_months: [{ total: 2, percentage: 100 }], + total: 2, + inactive: 1 + }, + ] + + expect(described_class.new.execute).to eq(months_included: 12, + cohorts: expected_cohorts) + end + end +end diff --git a/spec/services/delete_merged_branches_service_spec.rb b/spec/services/delete_merged_branches_service_spec.rb index a41a421fa6e..7b921f606f8 100644 --- a/spec/services/delete_merged_branches_service_spec.rb +++ b/spec/services/delete_merged_branches_service_spec.rb @@ -42,6 +42,19 @@ describe DeleteMergedBranchesService, services: true do expect { described_class.new(project, user).execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end + + context 'open merge requests' do + it 'does not delete branches from open merge requests' do + fork_link = create(:forked_project_link, forked_from_project: project) + create(:merge_request, :reopened, source_project: project, target_project: project, source_branch: 'branch-merged', target_branch: 'master') + create(:merge_request, :opened, source_project: fork_link.forked_to_project, target_project: project, target_branch: 'improve/awesome', source_branch: 'master') + + service.execute + + expect(project.repository.branch_names).to include('branch-merged') + expect(project.repository.branch_names).to include('improve/awesome') + end + end end context '#async_execute' do diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb index f2c2009bcbf..b06cefe071d 100644 --- a/spec/services/event_create_service_spec.rb +++ b/spec/services/event_create_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe EventCreateService, services: true do + include UserActivitiesHelpers + let(:service) { EventCreateService.new } describe 'Issues' do @@ -111,6 +113,19 @@ describe EventCreateService, services: true do end end + describe '#push', :redis do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + it 'creates a new event' do + expect { service.push(project, user, {}) }.to change { Event.count } + end + + it 'updates user last activity' do + expect { service.push(project, user, {}) }.to change { user_activity(user) } + end + end + describe 'Project' do let(:user) { create :user } let(:project) { create(:empty_project) } diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb index 26aa5b432d4..16bca66766a 100644 --- a/spec/services/files/update_service_spec.rb +++ b/spec/services/files/update_service_spec.rb @@ -7,7 +7,7 @@ describe Files::UpdateService do let(:user) { create(:user) } let(:file_path) { 'files/ruby/popen.rb' } let(:new_contents) { 'New Content' } - let(:target_branch) { project.default_branch } + let(:branch_name) { project.default_branch } let(:last_commit_sha) { nil } let(:commit_params) do @@ -19,7 +19,7 @@ describe Files::UpdateService do last_commit_sha: last_commit_sha, start_project: project, start_branch: project.default_branch, - target_branch: target_branch + branch_name: branch_name } end @@ -73,7 +73,7 @@ describe Files::UpdateService do end context 'when target branch is different than source branch' do - let(:target_branch) { "#{project.default_branch}-new" } + let(:branch_name) { "#{project.default_branch}-new" } it 'fires hooks only once' do expect(GitHooksService).to receive(:new).once.and_call_original diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb new file mode 100644 index 00000000000..3b35a3b8e3a --- /dev/null +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Members::AuthorizedDestroyService, services: true do + let(:member_user) { create(:user) } + let(:project) { create(:empty_project, :public) } + let(:group) { create(:group, :public) } + let(:group_project) { create(:empty_project, :public, group: group) } + + def number_of_assigned_issuables(user) + Issue.assigned_to(user).count + MergeRequest.assigned_to(user).count + end + + context 'Group member' do + it "unassigns issues and merge requests" do + group.add_developer(member_user) + + issue = create :issue, project: group_project, assignee: member_user + create :issue, assignee: member_user + merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user + create :merge_request, target_project: project, source_project: project, assignee: member_user + + member = group.members.find_by(user_id: member_user.id) + + expect { described_class.new(member, member_user).execute } + .to change { number_of_assigned_issuables(member_user) }.from(4).to(2) + + expect(issue.reload.assignee_id).to be_nil + expect(merge_request.reload.assignee_id).to be_nil + end + end + + context 'Project member' do + it "unassigns issues and merge requests" do + project.team << [member_user, :developer] + + create :issue, project: project, assignee: member_user + create :merge_request, target_project: project, source_project: project, assignee: member_user + + member = project.members.find_by(user_id: member_user.id) + + expect { described_class.new(member, member_user).execute } + .to change { number_of_assigned_issuables(member_user) }.from(2).to(0) + end + end +end diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb new file mode 100644 index 00000000000..8d67ebe3231 --- /dev/null +++ b/spec/services/users/activity_service_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Users::ActivityService, services: true do + include UserActivitiesHelpers + + let(:user) { create(:user) } + + subject(:service) { described_class.new(user, 'type') } + + describe '#execute', :redis do + context 'when last activity is nil' do + before do + service.execute + end + + it 'sets the last activity timestamp for the user' do + expect(last_hour_user_ids).to eq([user.id]) + end + + it 'updates the same user' do + service.execute + + expect(last_hour_user_ids).to eq([user.id]) + end + + it 'updates the timestamp of an existing user' do + Timecop.freeze(Date.tomorrow) do + expect { service.execute }.to change { user_activity(user) }.to(Time.now.to_i.to_s) + end + end + + describe 'other user' do + it 'updates other user' do + other_user = create(:user) + described_class.new(other_user, 'type').execute + + expect(last_hour_user_ids).to match_array([user.id, other_user.id]) + end + end + end + end + + def last_hour_user_ids + Gitlab::UserActivities.new. + select { |k, v| v >= 1.hour.ago.to_i.to_s }. + map { |k, _| k.to_i } + end +end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index 1a061ef069e..bb4542b1683 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -73,9 +73,15 @@ shared_examples 'discussion comments' do |resource_name| expect(page).not_to have_selector menu_selector end - it 'clicking the ul padding should not change the text' do + it 'clicking the ul padding or divider should not change the text' do find(menu_selector).trigger 'click' + expect(page).to have_selector menu_selector + expect(find(dropdown_selector)).to have_content 'Comment' + + find("#{menu_selector} .divider").trigger 'click' + + expect(page).to have_selector menu_selector expect(find(dropdown_selector)).to have_content 'Comment' end diff --git a/spec/support/matchers/user_activity_matchers.rb b/spec/support/matchers/user_activity_matchers.rb new file mode 100644 index 00000000000..ce3b683b6d2 --- /dev/null +++ b/spec/support/matchers/user_activity_matchers.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :have_an_activity_record do |expected| + match do |user| + expect(Gitlab::UserActivities.new.find { |k, _| k == user.id.to_s }).to be_present + end +end diff --git a/spec/support/mobile_helpers.rb b/spec/support/mobile_helpers.rb index 20d5849bcab..431f20a2a5c 100644 --- a/spec/support/mobile_helpers.rb +++ b/spec/support/mobile_helpers.rb @@ -1,4 +1,8 @@ module MobileHelpers + def resize_screen_xs + resize_window(767, 768) + end + def resize_screen_sm resize_window(900, 768) end diff --git a/spec/support/user_activities_helpers.rb b/spec/support/user_activities_helpers.rb new file mode 100644 index 00000000000..f7ca9a31edd --- /dev/null +++ b/spec/support/user_activities_helpers.rb @@ -0,0 +1,7 @@ +module UserActivitiesHelpers + def user_activity(user) + Gitlab::UserActivities.new. + find { |k, _| k == user.id.to_s }&. + second + end +end diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb new file mode 100644 index 00000000000..b6c080f36f4 --- /dev/null +++ b/spec/workers/gitlab_usage_ping_worker_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe GitlabUsagePingWorker do + subject { GitlabUsagePingWorker.new } + + it "sends POST request" do + stub_application_setting(usage_ping_enabled: true) + + stub_request(:post, "https://version.gitlab.com/usage_data"). + to_return(status: 200, body: '', headers: {}) + expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original + expect(subject).to receive(:try_obtain_lease).and_return(true) + + expect(subject.perform.response.code.to_i).to eq(200) + end + + it "does not run if usage ping is disabled" do + stub_application_setting(usage_ping_enabled: false) + + expect(subject).not_to receive(:try_obtain_lease) + expect(subject).not_to receive(:perform) + end +end diff --git a/spec/workers/schedule_update_user_activity_worker_spec.rb b/spec/workers/schedule_update_user_activity_worker_spec.rb new file mode 100644 index 00000000000..e583c3203aa --- /dev/null +++ b/spec/workers/schedule_update_user_activity_worker_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe ScheduleUpdateUserActivityWorker, :redis do + let(:now) { Time.now } + + before do + Gitlab::UserActivities.record('1', now) + Gitlab::UserActivities.record('2', now) + end + + it 'schedules UpdateUserActivityWorker once' do + expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '1' => now.to_i.to_s, '2' => now.to_i.to_s }) + + subject.perform + end + + context 'when specifying a batch size' do + it 'schedules UpdateUserActivityWorker twice' do + expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '1' => now.to_i.to_s }) + expect(UpdateUserActivityWorker).to receive(:perform_async).with({ '2' => now.to_i.to_s }) + + subject.perform(1) + end + end +end diff --git a/spec/workers/update_user_activity_worker_spec.rb b/spec/workers/update_user_activity_worker_spec.rb new file mode 100644 index 00000000000..43e9511f116 --- /dev/null +++ b/spec/workers/update_user_activity_worker_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe UpdateUserActivityWorker, :redis do + let(:user_active_2_days_ago) { create(:user, current_sign_in_at: 10.months.ago) } + let(:user_active_yesterday_1) { create(:user) } + let(:user_active_yesterday_2) { create(:user) } + let(:user_active_today) { create(:user) } + let(:data) do + { + user_active_2_days_ago.id.to_s => 2.days.ago.at_midday.to_i.to_s, + user_active_yesterday_1.id.to_s => 1.day.ago.at_midday.to_i.to_s, + user_active_yesterday_2.id.to_s => 1.day.ago.at_midday.to_i.to_s, + user_active_today.id.to_s => Time.now.to_i.to_s + } + end + + it 'updates users.last_activity_on' do + subject.perform(data) + + aggregate_failures do + expect(user_active_2_days_ago.reload.last_activity_on).to eq(2.days.ago.to_date) + expect(user_active_yesterday_1.reload.last_activity_on).to eq(1.day.ago.to_date) + expect(user_active_yesterday_2.reload.last_activity_on).to eq(1.day.ago.to_date) + expect(user_active_today.reload.reload.last_activity_on).to eq(Date.today) + end + end + + it 'deletes the pairs from Redis' do + data.each { |id, time| Gitlab::UserActivities.record(id, time) } + + subject.perform(data) + + expect(Gitlab::UserActivities.new.to_a).to be_empty + end +end diff --git a/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml b/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml index d3bb388a1e7..636cb0a9a99 100644 --- a/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml @@ -41,7 +41,7 @@ review: APP: $CI_COMMIT_REF_NAME APP_HOST: $CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$OPENSHIFT_DOMAIN environment: - name: review/$CI_COMMIT_REF_SLUG + name: review/$CI_COMMIT_REF_NAME url: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$OPENSHIFT_DOMAIN on_stop: stop-review only: @@ -59,7 +59,7 @@ stop-review: APP: $CI_COMMIT_REF_NAME GIT_STRATEGY: none environment: - name: review/$CI_COMMIT_REF_SLUG + name: review/$CI_COMMIT_REF_NAME action: stop only: - branches diff --git a/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml new file mode 100644 index 00000000000..555a51d35b9 --- /dev/null +++ b/vendor/gitlab-ci-yml/autodeploy/Kubernetes-with-canary.gitlab-ci.yml @@ -0,0 +1,84 @@ +# Explanation on the scripts: +# https://gitlab.com/gitlab-examples/kubernetes-deploy/blob/master/README.md +image: registry.gitlab.com/gitlab-examples/kubernetes-deploy + +variables: + # Application deployment domain + KUBE_DOMAIN: domain.example.com + +stages: + - build + - test + - review + - staging + - canary + - production + - cleanup + +build: + stage: build + script: + - command build + only: + - branches + +canary: + stage: canary + script: + - command canary + environment: + name: production + url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN + when: manual + only: + - master + +production: + stage: production + script: + - command deploy + environment: + name: production + url: http://$CI_PROJECT_NAME.$KUBE_DOMAIN + when: manual + only: + - master + +staging: + stage: staging + script: + - command deploy + environment: + name: staging + url: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN + only: + - master + +review: + stage: review + script: + - command deploy + environment: + name: review/$CI_COMMIT_REF_NAME + url: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN + on_stop: stop_review + only: + - branches + except: + - master + +stop_review: + stage: cleanup + variables: + GIT_STRATEGY: none + script: + - command destroy + environment: + name: review/$CI_COMMIT_REF_NAME + action: stop + when: manual + allow_failure: true + only: + - branches + except: + - master diff --git a/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml index c644560647f..ee830ec2eb0 100644 --- a/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/autodeploy/Kubernetes.gitlab-ci.yml @@ -23,8 +23,6 @@ build: production: stage: production - variables: - CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME.$KUBE_DOMAIN script: - command deploy environment: @@ -36,8 +34,6 @@ production: staging: stage: staging - variables: - CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME-staging.$KUBE_DOMAIN script: - command deploy environment: @@ -48,8 +44,6 @@ staging: review: stage: review - variables: - CI_ENVIRONMENT_URL: http://$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$KUBE_DOMAIN script: - command deploy environment: diff --git a/yarn.lock b/yarn.lock index e16cd9c3673..90ba39a3251 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1988,6 +1988,10 @@ eslint-plugin-jasmine@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/eslint-plugin-jasmine/-/eslint-plugin-jasmine-2.2.0.tgz#7135879383c39a667c721d302b9f20f0389543de" +eslint-plugin-promise@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-3.5.0.tgz#78fbb6ffe047201627569e85a6c5373af2a68fca" + eslint@^3.10.1: version "3.15.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.15.0.tgz#bdcc6a6c5ffe08160e7b93c066695362a91e30f2" |