diff options
author | Luke Bennett <lukeeeebennettplus@gmail.com> | 2018-04-05 18:45:35 +0100 |
---|---|---|
committer | Luke Bennett <lukeeeebennettplus@gmail.com> | 2018-04-05 18:45:35 +0100 |
commit | 8131a02fef9241f396c827cb7613ddd307f0a551 (patch) | |
tree | eca9cac2c0ba6e228b42bab90d8fd0cf0a9b191e /app | |
parent | ee1954efc19470c477ddbdf965731dc25598e6b0 (diff) | |
parent | f103475766fecc6e6fdf996e9cfaaa41e795962f (diff) | |
download | gitlab-ce-8131a02fef9241f396c827cb7613ddd307f0a551.tar.gz |
Merge remote-tracking branch 'origin/master' into deprecation-warning-for-dynamic-milestones
Diffstat (limited to 'app')
341 files changed, 6856 insertions, 3378 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico Binary files differnew file mode 100644 index 00000000000..48b1095370d --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico Binary files differnew file mode 100644 index 00000000000..623c728faf6 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico Binary files differnew file mode 100644 index 00000000000..3073fe5a761 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico Binary files differnew file mode 100644 index 00000000000..6c713d7b675 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico Binary files differnew file mode 100644 index 00000000000..dbf855fdafd --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico Binary files differnew file mode 100644 index 00000000000..ccd00606aeb --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico Binary files differnew file mode 100644 index 00000000000..968e7c4c2d4 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico Binary files differnew file mode 100644 index 00000000000..7e3be35cc3a --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico Binary files differnew file mode 100644 index 00000000000..a1fb6e91d65 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico Binary files differnew file mode 100644 index 00000000000..5d931619fb2 --- /dev/null +++ b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico diff --git a/app/assets/images/favicon-yellow.ico b/app/assets/images/favicon-yellow.ico Binary files differnew file mode 100644 index 00000000000..b650f277fb6 --- /dev/null +++ b/app/assets/images/favicon-yellow.ico diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index cbcefb2c18f..8ad3d18b302 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -10,6 +10,9 @@ const Api = { projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', + mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', + mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', groupLabelsPath: '/groups/:namespace_path/-/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -22,25 +25,27 @@ const Api = { createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { - const url = Api.buildUrl(Api.groupPath) - .replace(':id', groupId); - return axios.get(url) - .then(({ data }) => { - callback(data); + const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); + return axios.get(url).then(({ data }) => { + callback(data); - return data; - }); + return data; + }); }, // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); - return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), - }) + return axios + .get(url, { + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), + }) .then(({ data }) => { callback(data); @@ -51,12 +56,13 @@ const Api = { // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, @@ -73,9 +79,10 @@ const Api = { defaults.membership = true; } - return axios.get(url, { - params: Object.assign(defaults, options), - }) + return axios + .get(url, { + params: Object.assign(defaults, options), + }) .then(({ data }) => { callback(data); @@ -85,8 +92,32 @@ const Api = { // Return single project project(projectPath) { - const url = Api.buildUrl(Api.projectPath) - .replace(':id', encodeURIComponent(projectPath)); + const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); + + return axios.get(url); + }, + + // Return Merge Request for project + mergeRequest(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestChangesPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + mergeRequestVersions(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestVersionsPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); return axios.get(url); }, @@ -102,30 +133,30 @@ const Api = { url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath); } - return axios.post(url, { - label: data, - }) + return axios + .post(url, { + label: data, + }) .then(res => callback(res.data)) .catch(e => callback(e.response.data)); }, // Return group projects list. Filtered by query groupProjects(groupId, query, callback) { - const url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', groupId); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath) - .replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', @@ -136,39 +167,34 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) .replace(':id', encodeURIComponent(id)) - .replace(':branch', branch); + .replace(':branch', encodeURIComponent(branch)); return axios.get(url); }, // Return text for a specific license licenseText(key, data, callback) { - const url = Api.buildUrl(Api.licensePath) - .replace(':key', key); - return axios.get(url, { - params: data, - }) + const url = Api.buildUrl(Api.licensePath).replace(':key', key); + return axios + .get(url, { + params: data, + }) .then(res => callback(res.data)); }, gitignoreText(key, callback) { - const url = Api.buildUrl(Api.gitignorePath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, gitlabCiYml(key, callback) { - const url = Api.buildUrl(Api.gitlabCiYmlPath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, dockerfileYml(key, callback) { const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + return axios.get(url).then(({ data }) => callback(data)); }, issueTemplate(namespacePath, projectPath, key, type, callback) { @@ -177,7 +203,8 @@ const Api = { .replace(':type', type) .replace(':project_path', projectPath) .replace(':namespace_path', namespacePath); - return axios.get(url) + return axios + .get(url) .then(({ data }) => callback(null, data)) .catch(callback); }, @@ -185,10 +212,13 @@ const Api = { users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), }); }, diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 6da33a26e58..0e1ca7fe883 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; +import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -300,7 +300,7 @@ class AwardsHandler { } isInVueNoteablePage() { - return isInIssuePage() || this.isVueMRDiscussions(); + return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions(); } getVotesBlock() { diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index 7dcf1aeed17..eb4e59d12b1 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -31,7 +31,7 @@ export default function renderMath($els) { if (!$els.length) return; Promise.all([ import(/* webpackChunkName: 'katex' */ 'katex'), - import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'), + import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.min.css'), ]).then(([katex]) => { renderWithKaTeX($els, katex); }).catch(() => flash(__('An error occurred while rendering KaTeX'))); diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index d78d4701974..7c90597f77c 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -19,7 +19,7 @@ export default class BoardService { } static generateIssuePath(boardId, id) { - return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`; + return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`; } all() { diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index 745f3404295..e177a3bfdc7 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -33,7 +33,7 @@ export default class VariableList { selector: '.js-ci-variable-input-key', default: '', }, - value: { + secret_value: { selector: '.js-ci-variable-input-value', default: '', }, @@ -105,7 +105,7 @@ export default class VariableList { setupToggleButtons($row[0]); // Reset the resizable textarea - $row.find(this.inputMap.value.selector).css('height', ''); + $row.find(this.inputMap.secret_value.selector).css('height', ''); const $environmentSelect = $row.find('.js-variable-environment-toggle'); if ($environmentSelect.length) { diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index 46232726510..d62d3c23654 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,4 +1,5 @@ // ECMAScript polyfills +import 'core-js/fn/array/fill'; import 'core-js/fn/array/find'; import 'core-js/fn/array/find-index'; import 'core-js/fn/array/from'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index e6390f0855b..d7e1de18d09 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -26,8 +26,8 @@ export default class FilteredSearchDropdownManager { this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; this.groupsOnly = isGroup; - this.groupAncestor = isGroupAncestor; - this.isGroupDecendent = isGroupDecendent; + this.includeAncestorGroups = isGroupAncestor; + this.includeDescendantGroups = isGroupDecendent; this.setupMapping(); @@ -108,7 +108,19 @@ export default class FilteredSearchDropdownManager { } getLabelsEndpoint() { - const endpoint = `${this.baseEndpoint}/labels.json`; + let endpoint = `${this.baseEndpoint}/labels.json?`; + + if (this.groupsOnly) { + endpoint = `${endpoint}only_group_labels=true&`; + } + + if (this.includeAncestorGroups) { + endpoint = `${endpoint}include_ancestor_groups=true&`; + } + + if (this.includeDescendantGroups) { + endpoint = `${endpoint}include_descendant_groups=true`; + } return endpoint; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 71b7e80335b..cf5ba1e1771 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -21,7 +21,7 @@ export default class FilteredSearchManager { constructor({ page, isGroup = false, - isGroupAncestor = false, + isGroupAncestor = true, isGroupDecendent = false, filteredSearchTokenKeys = FilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', @@ -86,6 +86,7 @@ export default class FilteredSearchManager { page: this.page, isGroup: this.isGroup, isGroupAncestor: this.isGroupAncestor, + isGroupDecendent: this.isGroupDecendent, filteredSearchTokenKeys: this.filteredSearchTokenKeys, }); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 8259133c95b..7e9770a9ea2 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -54,6 +54,7 @@ class GfmAutoComplete { alias: 'commands', searchKey: 'search', skipSpecialCharacterTest: true, + skipMarkdownCharacterTest: true, data: GfmAutoComplete.defaultLoadingData, displayTpl(value) { if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template; @@ -376,15 +377,23 @@ class GfmAutoComplete { return $.fn.atwho.default.callbacks.filter(query, data, searchKey); }, beforeInsert(value) { - let resultantValue = value; + let withoutAt = value.substring(1); + const at = value.charAt(); + if (value && !this.setting.skipSpecialCharacterTest) { - const withoutAt = value.substring(1); - const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/; + const regex = at === '~' ? /\W|^\d+$/ : /\W/; if (withoutAt && regex.test(withoutAt)) { - resultantValue = `${value.charAt()}"${withoutAt}"`; + withoutAt = `"${withoutAt}"`; } } - return resultantValue; + + // We can ignore this for quick actions because they are processed + // before Markdown. + if (!this.setting.skipMarkdownCharacterTest) { + withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&'); + } + + return `${at}${withoutAt}`; }, matcher(flag, subtext) { const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 86b34a6e360..fa48d7d1915 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -753,7 +753,7 @@ GitLabDropdown = (function() { } if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { - return; + return [selectedObject]; } if (el.hasClass(ACTIVE_CLASS) && value !== 0) { diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index 0c54c992e51..037e3efb4ce 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -1,25 +1,25 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; +import icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + changedIcon() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - changedIcon() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - changedIconClass() { - return `multi-${this.changedIcon}`; - }, + changedIconClass() { + return `multi-${this.changedIcon}`; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 18934af004a..560cdd941cd 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,38 +1,36 @@ <script> - import { mapActions } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; - import router from '../../ide_router'; +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - icon, +export default { + components: { + Icon, + }, + props: { + file: { + type: Object, + required: true, }, - props: { - file: { - type: Object, - required: true, - }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; }, - computed: { - iconName() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; - }, - iconClass() { - return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; - }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, - methods: { - ...mapActions([ - 'discardFileChanges', - 'updateViewer', - ]), - openFileInEditor(file) { - this.updateViewer('diff'); - - router.push(`/project${file.url}`); - }, + }, + methods: { + ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), + openFileInEditor(file) { + return this.openPendingTab(file).then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }); }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 170347881e0..0c44a755f56 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,31 +1,44 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; - export default { - components: { - Icon, +export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, }, - props: { - hasChanges: { - type: Boolean, - required: false, - default: false, - }, - viewer: { - type: String, - required: true, - }, - showShadow: { - type: Boolean, - required: true, - }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - changeMode(mode) { - this.$emit('click', mode); - }, + viewer: { + type: String, + required: true, }, - }; + showShadow: { + type: Boolean, + required: true, + }, + }, + computed: { + mergeReviewLine() { + return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { + mergeRequestId: this.mergeRequestId, + }); + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, +}; </script> <template> @@ -43,7 +56,10 @@ }" data-toggle="dropdown" > - <template v-if="viewer === 'editor'"> + <template v-if="viewer === 'mrdiff' && mergeRequestId"> + {{ mergeReviewLine }} + </template> + <template v-else-if="viewer === 'editor'"> {{ __('Editing') }} </template> <template v-else> @@ -57,6 +73,29 @@ </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> + <template v-if="mergeRequestId"> + <li> + <a + href="#" + @click.prevent="changeMode('mrdiff')" + :class="{ + 'is-active': viewer === 'mrdiff', + }" + > + <strong class="dropdown-menu-inner-title"> + {{ mergeReviewLine }} + </strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes with the merge request target branch') }} + </span> + </a> + </li> + <li + role="separator" + class="divider" + > + </li> + </template> <li> <a href="#" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 015e750525a..1c237c0ec97 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,51 +1,49 @@ <script> - import { mapState, mapGetters } from 'vuex'; - import ideSidebar from './ide_side_bar.vue'; - import ideContextbar from './ide_context_bar.vue'; - import repoTabs from './repo_tabs.vue'; - import repoFileButtons from './repo_file_buttons.vue'; - import ideStatusBar from './ide_status_bar.vue'; - import repoEditor from './repo_editor.vue'; +import { mapState, mapGetters } from 'vuex'; +import ideSidebar from './ide_side_bar.vue'; +import ideContextbar from './ide_context_bar.vue'; +import repoTabs from './repo_tabs.vue'; +import ideStatusBar from './ide_status_bar.vue'; +import repoEditor from './repo_editor.vue'; - export default { - components: { - ideSidebar, - ideContextbar, - repoTabs, - repoFileButtons, - ideStatusBar, - repoEditor, +export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, }, - props: { - emptyStateSvgPath: { - type: String, - required: true, - }, - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, + noChangesStateSvgPath: { + type: String, + required: true, }, - computed: { - ...mapState(['changedFiles', 'openFiles', 'viewer']), - ...mapGetters(['activeFile', 'hasChanges']), + committedStateSvgPath: { + type: String, + required: true, }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = e => { - if (!this.changedFiles.length) return undefined; + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; - Object.assign(e, { - returnValue, - }); - return returnValue; - }; - }, - }; + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, +}; </script> <template> @@ -60,17 +58,16 @@ v-if="activeFile" > <repo-tabs + :active-file="activeFile" :files="openFiles" :viewer="viewer" :has-changes="hasChanges" + :merge-request-id="currentMergeRequestId" /> <repo-editor class="multi-file-edit-pane-content" :file="activeFile" /> - <repo-file-buttons - :file="activeFile" - /> <ide-status-bar :file="activeFile" /> diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue new file mode 100644 index 00000000000..6d07329df71 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue @@ -0,0 +1,83 @@ +<script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + showButtons() { + return ( + this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink + ); + }, + rawDownloadButtonLabel() { + return this.file.binary ? __('Download') : __('Raw'); + }, + }, +}; +</script> + +<template> + <div + v-if="showButtons" + class="pull-right ide-btn-group" + > + <a + v-tooltip + :href="file.blamePath" + :title="__('Blame')" + class="btn btn-xs btn-transparent blame" + > + <icon + name="blame" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.commitsPath" + :title="__('History')" + class="btn btn-xs btn-transparent history" + > + <icon + name="history" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.permalink" + :title="__('Permalink')" + class="btn btn-xs btn-transparent permalink" + > + <icon + name="link" + :size="16" + /> + </a> + <a + v-tooltip + :href="file.rawPath" + target="_blank" + class="btn btn-xs btn-transparent prepend-left-10 raw" + rel="noopener noreferrer" + :title="rawDownloadButtonLabel"> + <icon + name="download" + :size="16" + /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue new file mode 100644 index 00000000000..8a440902dfc --- /dev/null +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -0,0 +1,23 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + icon, + }, + directives: { + tooltip, + }, +}; +</script> + +<template> + <icon + name="git-merge" + v-tooltip + title="__('Part of merge request changes')" + css-classes="ide-file-changed-icon" + :size="12" + /> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index e73d1ce839f..8a709b31ea0 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,11 +1,17 @@ <script> /* global monaco */ -import { mapState, mapActions } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; +import IdeFileButtons from './ide_file_buttons.vue'; export default { + components: { + ContentViewer, + IdeFileButtons, + }, props: { file: { type: Object, @@ -13,31 +19,40 @@ export default { }, }, computed: { - ...mapState([ - 'leftPanelCollapsed', - 'rightPanelCollapsed', - 'viewer', - 'delayViewerUpdated', - ]), + ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), + ...mapGetters(['currentMergeRequest']), shouldHideEditor() { return this.file && this.file.binary && !this.file.raw; }, + editTabCSS() { + return { + active: this.file.viewMode === 'edit', + }; + }, + previewTabCSS() { + return { + active: this.file.viewMode === 'preview', + }; + }, }, watch: { file(oldVal, newVal) { - if (newVal.path !== this.file.path) { + // Compare key to allow for files opened in review mode to be cached differently + if (newVal.key !== this.file.key) { this.initMonaco(); } }, - leftPanelCollapsed() { - this.editor.updateDimensions(); - }, rightPanelCollapsed() { this.editor.updateDimensions(); }, viewer() { this.createEditorInstance(); }, + panelResizing() { + if (!this.panelResizing) { + this.editor.updateDimensions(); + } + }, }, beforeDestroy() { this.editor.dispose(); @@ -59,6 +74,7 @@ export default { 'changeFileContent', 'setFileLanguage', 'setEditorPosition', + 'setFileViewMode', 'setFileEOL', 'updateViewer', 'updateDelayViewerUpdated', @@ -68,9 +84,14 @@ export default { this.editor.clearEditor(); - this.getRawFileData(this.file) + this.getRawFileData({ + path: this.file.path, + baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '', + }) .then(() => { - const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + const viewerPromise = this.delayViewerUpdated + ? this.updateViewer(this.file.pending ? 'diff' : 'editor') + : Promise.resolve(); return viewerPromise; }) @@ -78,7 +99,7 @@ export default { this.updateDelayViewerUpdated(false); this.createEditorInstance(); }) - .catch((err) => { + .catch(err => { flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); throw err; }); @@ -101,9 +122,13 @@ export default { this.model = this.editor.createModel(this.file); - this.editor.attachModel(this.model); + if (this.viewer === 'mrdiff') { + this.editor.attachMergeRequestModel(this.model); + } else { + this.editor.attachModel(this.model); + } - this.model.onChange((model) => { + this.model.onChange(model => { const { file } = model; if (file.active) { @@ -147,15 +172,47 @@ export default { class="blob-viewer-container blob-editor-container" > <div - v-if="shouldHideEditor" - v-html="file.html" - > + class="ide-mode-tabs clearfix" + v-if="!shouldHideEditor"> + <ul class="nav-links pull-left"> + <li :class="editTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> + <template v-if="viewer === 'editor'"> + {{ __('Edit') }} + </template> + <template v-else> + {{ __('Review') }} + </template> + </a> + </li> + <li + v-if="file.previewMode" + :class="previewTabCSS"> + <a + href="javascript:void(0);" + role="button" + @click.prevent="setFileViewMode({ file, viewMode:'preview' })"> + {{ file.previewMode.previewTitle }} + </a> + </li> + </ul> + <ide-file-buttons + :file="file" + /> </div> <div - v-show="!shouldHideEditor" + v-show="!shouldHideEditor && file.viewMode === 'edit'" ref="editor" class="multi-file-editor-holder" > </div> + <content-viewer + v-if="!shouldHideEditor && file.viewMode === 'preview'" + :content="file.content || file.raw" + :path="file.path" + :project-path="file.projectId"/> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 297b9c2628f..3b5068d4910 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -6,6 +6,7 @@ import router from '../ide_router'; import newDropdown from './new_dropdown/index.vue'; import fileStatusIcon from './repo_file_status_icon.vue'; import changedFileIcon from './changed_file_icon.vue'; +import mrFileIcon from './mr_file_icon.vue'; export default { name: 'RepoFile', @@ -15,6 +16,7 @@ export default { fileStatusIcon, fileIcon, changedFileIcon, + mrFileIcon, }, props: { file: { @@ -56,18 +58,11 @@ export default { ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), clickFile() { // Manual Action if a tree is selected/opened - if ( - this.isTree && - this.$router.currentRoute.path === `/project${this.file.url}` - ) { + if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) { this.toggleTreeOpen(this.file.path); } - const delayPromise = this.file.changed - ? Promise.resolve() - : this.updateDelayViewerUpdated(true); - - return delayPromise.then(() => { + return this.updateDelayViewerUpdated(true).then(() => { router.push(`/project${this.file.url}`); }); }, @@ -102,11 +97,15 @@ export default { :file="file" /> </span> - <changed-file-icon - :file="file" - v-if="file.changed || file.tempFile" - class="prepend-top-5 pull-right" - /> + <span class="pull-right"> + <mr-file-icon + v-if="file.mrChange" + /> + <changed-file-icon + :file="file" + v-if="file.changed || file.tempFile" + /> + </span> <new-dropdown v-if="isTree" :project-id="file.projectId" diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue deleted file mode 100644 index 4ea8cf7504b..00000000000 --- a/app/assets/javascripts/ide/components/repo_file_buttons.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -export default { - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - showButtons() { - return this.file.rawPath || - this.file.blamePath || - this.file.commitsPath || - this.file.permalink; - }, - rawDownloadButtonLabel() { - return this.file.binary ? 'Download' : 'Raw'; - }, - }, -}; -</script> - -<template> - <div - v-if="showButtons" - class="multi-file-editor-btn-group" - > - <a - :href="file.rawPath" - target="_blank" - class="btn btn-default btn-sm raw" - rel="noopener noreferrer"> - {{ rawDownloadButtonLabel }} - </a> - - <div - class="btn-group" - role="group" - aria-label="File actions" - > - <a - :href="file.blamePath" - class="btn btn-default btn-sm blame" - > - Blame - </a> - <a - :href="file.commitsPath" - class="btn btn-default btn-sm history" - > - History - </a> - <a - :href="file.permalink" - class="btn btn-default btn-sm permalink" - > - Permalink - </a> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 25d311142d5..97589e116c5 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,27 +1,27 @@ <script> - import icon from '~/vue_shared/components/icon.vue'; - import tooltip from '~/vue_shared/directives/tooltip'; - import '~/lib/utils/datetime_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import '~/lib/utils/datetime_utility'; - export default { - components: { - icon, +export default { + components: { + icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, }, - directives: { - tooltip, + }, + computed: { + lockTooltip() { + return `Locked by ${this.file.file_lock.user.name}`; }, - props: { - file: { - type: Object, - required: true, - }, - }, - computed: { - lockTooltip() { - return `Locked by ${this.file.file_lock.user.name}`; - }, - }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index c337bc813e6..304a73ed1ad 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,60 +1,64 @@ <script> - import { mapActions } from 'vuex'; +import { mapActions } from 'vuex'; - import fileIcon from '~/vue_shared/components/file_icon.vue'; - import icon from '~/vue_shared/components/icon.vue'; - import fileStatusIcon from './repo_file_status_icon.vue'; - import changedFileIcon from './changed_file_icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileStatusIcon from './repo_file_status_icon.vue'; +import ChangedFileIcon from './changed_file_icon.vue'; - export default { - components: { - fileStatusIcon, - fileIcon, - icon, - changedFileIcon, +export default { + components: { + FileStatusIcon, + FileIcon, + Icon, + ChangedFileIcon, + }, + props: { + tab: { + type: Object, + required: true, }, - props: { - tab: { - type: Object, - required: true, - }, + }, + data() { + return { + tabMouseOver: false, + }; + }, + computed: { + closeLabel() { + if (this.tab.changed || this.tab.tempFile) { + return `${this.tab.name} changed`; + } + return `Close ${this.tab.name}`; }, - data() { - return { - tabMouseOver: false, - }; - }, - computed: { - closeLabel() { - if (this.tab.changed || this.tab.tempFile) { - return `${this.tab.name} changed`; - } - return `Close ${this.tab.name}`; - }, - showChangedIcon() { - return this.tab.changed ? !this.tabMouseOver : false; - }, + showChangedIcon() { + return this.tab.changed ? !this.tabMouseOver : false; }, + }, + + methods: { + ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']), + clickFile(tab) { + this.updateDelayViewerUpdated(true); - methods: { - ...mapActions([ - 'closeFile', - ]), - clickFile(tab) { + if (tab.pending) { + this.openPendingTab(tab); + } else { this.$router.push(`/project${tab.url}`); - }, - mouseOverTab() { - if (this.tab.changed) { - this.tabMouseOver = true; - } - }, - mouseOutTab() { - if (this.tab.changed) { - this.tabMouseOver = false; - } - }, + } + }, + mouseOverTab() { + if (this.tab.changed) { + this.tabMouseOver = true; + } + }, + mouseOutTab() { + if (this.tab.changed) { + this.tabMouseOver = false; + } }, - }; + }, +}; </script> <template> @@ -66,7 +70,7 @@ <button type="button" class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab.path)" + @click.stop.prevent="closeFile(tab)" :aria-label="closeLabel" > <icon @@ -82,7 +86,9 @@ <div class="multi-file-tab" - :class="{active : tab.active }" + :class="{ + active: tab.active + }" :title="tab.url" > <file-icon diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 8ea64ddf84a..7bd646ba9b0 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,42 +1,62 @@ <script> - import { mapActions } from 'vuex'; - import RepoTab from './repo_tab.vue'; - import EditorMode from './editor_mode_dropdown.vue'; +import { mapActions } from 'vuex'; +import RepoTab from './repo_tab.vue'; +import EditorMode from './editor_mode_dropdown.vue'; +import router from '../ide_router'; - export default { - components: { - RepoTab, - EditorMode, +export default { + components: { + RepoTab, + EditorMode, + }, + props: { + activeFile: { + type: Object, + required: true, }, - props: { - files: { - type: Array, - required: true, - }, - viewer: { - type: String, - required: true, - }, - hasChanges: { - type: Boolean, - required: true, - }, + files: { + type: Array, + required: true, }, - data() { - return { - showShadow: false, - }; + viewer: { + type: String, + required: true, }, - updated() { - if (!this.$refs.tabsScroller) return; - - this.showShadow = - this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + hasChanges: { + type: Boolean, + required: true, + }, + mergeRequestId: { + type: String, + required: false, + default: '', }, - methods: { - ...mapActions(['updateViewer']), + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer', 'removePendingTab']), + openFileViewer(viewer) { + this.updateViewer(viewer); + + if (this.activeFile.pending) { + return this.removePendingTab(this.activeFile).then(() => { + router.push(`/project${this.activeFile.url}`); + }); + } + + return null; }, - }; + }, +}; </script> <template> @@ -55,7 +75,8 @@ :viewer="viewer" :show-shadow="showShadow" :has-changes="hasChanges" - @click="updateViewer" + :merge-request-id="mergeRequestId" + @click="openFileViewer" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index faa690ecba0..5ea2a2f6825 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,67 +1,64 @@ <script> - import { mapActions, mapState } from 'vuex'; - import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { mapActions, mapState } from 'vuex'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; - export default { - components: { - PanelResizer, +export default { + components: { + PanelResizer, + }, + props: { + collapsible: { + type: Boolean, + required: true, }, - props: { - collapsible: { - type: Boolean, - required: true, - }, - initialWidth: { - type: Number, - required: true, - }, - minSize: { - type: Number, - required: false, - default: 200, - }, - side: { - type: String, - required: true, - }, + initialWidth: { + type: Number, + required: true, }, - data() { - return { - width: this.initialWidth, - }; + minSize: { + type: Number, + required: false, + default: 340, }, - computed: { - ...mapState({ - collapsed(state) { - return state[`${this.side}PanelCollapsed`]; - }, - }), - panelStyle() { - if (!this.collapsed) { - return { - width: `${this.width}px`, - }; - } - - return {}; - }, + side: { + type: String, + required: true, }, - methods: { - ...mapActions([ - 'setPanelCollapsedStatus', - 'setResizingStatus', - ]), - toggleFullbarCollapsed() { - if (this.collapsed && this.collapsible) { - this.setPanelCollapsedStatus({ - side: this.side, - collapsed: !this.collapsed, - }); - } + }, + data() { + return { + width: this.initialWidth, + }; + }, + computed: { + ...mapState({ + collapsed(state) { + return state[`${this.side}PanelCollapsed`]; }, + }), + panelStyle() { + if (!this.collapsed) { + return { + width: `${this.width}px`, + }; + } + + return {}; + }, + }, + methods: { + ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']), + toggleFullbarCollapsed() { + if (this.collapsed && this.collapsible) { + this.setPanelCollapsedStatus({ + side: this.side, + collapsed: !this.collapsed, + }); + } }, - maxSize: (window.innerWidth / 2), - }; + }, + maxSize: window.innerWidth / 2, +}; </script> <template> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index db89c1d44db..20983666b4a 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -44,7 +44,7 @@ const router = new VueRouter({ component: EmptyRouterComponent, }, { - path: 'mr/:mrid', + path: 'merge_requests/:mrid', component: EmptyRouterComponent, }, ], @@ -76,10 +76,12 @@ router.beforeEach((to, from, next) => { .then(() => { if (to.params[0]) { const path = - to.params[0].slice(-1) === '/' - ? to.params[0].slice(0, -1) - : to.params[0]; - const treeEntry = store.state.entries[path]; + to.params[0].slice(-1) === '/' ? to.params[0].slice(0, -1) : to.params[0]; + const treeEntryKey = Object.keys(store.state.entries).find( + key => key === path && !store.state.entries[key].pending, + ); + const treeEntry = store.state.entries[treeEntryKey]; + if (treeEntry) { store.dispatch('handleTreeEntryAction', treeEntry); } @@ -96,6 +98,60 @@ router.beforeEach((to, from, next) => { ); throw e; }); + } else if (to.params.mrid) { + store.dispatch('updateViewer', 'mrdiff'); + + store + .dispatch('getMergeRequestData', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }) + .then(mr => { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + + return store.dispatch('getFiles', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + }) + .then(() => + store.dispatch('getMergeRequestVersions', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(() => + store.dispatch('getMergeRequestChanges', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }), + ) + .then(mrChanges => { + mrChanges.changes.forEach((change, ind) => { + const changeTreeEntry = store.state.entries[change.new_path]; + + if (changeTreeEntry) { + store.dispatch('setFileMrChange', { + file: changeTreeEntry, + mrChange: change, + }); + + if (ind < 10) { + store.dispatch('getFileData', { + path: change.new_path, + makeFileActive: ind === 0, + }); + } + } + }); + }) + .catch(e => { + flash('Error while loading the merge request. Please try again.'); + throw e; + }); } }) .catch(e => { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 73cd684351c..e47adae99ed 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -13,25 +13,31 @@ export default class Model { (this.originalModel = this.monaco.editor.createModel( this.file.raw, undefined, - new this.monaco.Uri(null, null, `original/${this.file.path}`), + new this.monaco.Uri(null, null, `original/${this.file.key}`), )), (this.model = this.monaco.editor.createModel( this.content, undefined, - new this.monaco.Uri(null, null, this.file.path), + new this.monaco.Uri(null, null, this.file.key), )), ); + if (this.file.mrChange) { + this.disposable.add( + (this.baseModel = this.monaco.editor.createModel( + this.file.baseRaw, + undefined, + new this.monaco.Uri(null, null, `target/${this.file.path}`), + )), + ); + } this.events = new Map(); this.updateContent = this.updateContent.bind(this); this.dispose = this.dispose.bind(this); - eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose); - eventHub.$on( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); } get url() { @@ -47,7 +53,7 @@ export default class Model { } get path() { - return this.file.path; + return this.file.key; } getModel() { @@ -58,6 +64,10 @@ export default class Model { return this.originalModel; } + getBaseModel() { + return this.baseModel; + } + setValue(value) { this.getModel().setValue(value); } @@ -78,13 +88,7 @@ export default class Model { this.disposable.dispose(); this.events.clear(); - eventHub.$off( - `editor.update.model.dispose.${this.file.path}`, - this.dispose, - ); - eventHub.$off( - `editor.update.model.content.${this.file.path}`, - this.updateContent, - ); + eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); + eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); } } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 57d5e59a88b..0e7b563b5d6 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -9,17 +9,17 @@ export default class ModelManager { this.models = new Map(); } - hasCachedModel(path) { - return this.models.has(path); + hasCachedModel(key) { + return this.models.has(key); } - getModel(path) { - return this.models.get(path); + getModel(key) { + return this.models.get(key); } addModel(file) { - if (this.hasCachedModel(file.path)) { - return this.getModel(file.path); + if (this.hasCachedModel(file.key)) { + return this.getModel(file.key); } const model = new Model(this.monaco, file); @@ -27,7 +27,7 @@ export default class ModelManager { this.disposable.add(model); eventHub.$on( - `editor.update.model.dispose.${file.path}`, + `editor.update.model.dispose.${file.key}`, this.removeCachedModel.bind(this, file), ); @@ -35,12 +35,9 @@ export default class ModelManager { } removeCachedModel(file) { - this.models.delete(file.path); + this.models.delete(file.key); - eventHub.$off( - `editor.update.model.dispose.${file.path}`, - this.removeCachedModel, - ); + eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel); } dispose() { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 38de2fe2b27..001737d6ee8 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -65,6 +65,11 @@ export default class Editor { (this.instance = this.monaco.editor.createDiffEditor(domElement, { ...defaultEditorOptions, readOnly: true, + quickSuggestions: false, + occurrencesHighlight: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, + renderSideBySide: Editor.renderSideBySide(domElement), })), ); @@ -77,7 +82,7 @@ export default class Editor { } attachModel(model) { - if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') { + if (this.isDiffEditorType) { this.instance.setModel({ original: model.getOriginalModel(), modified: model.getModel(), @@ -105,11 +110,19 @@ export default class Editor { if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); } + attachMergeRequestModel(model) { + this.instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + + this.monaco.editor.createDiffNavigator(this.instance, { + alwaysRevealFirst: true, + }); + } + setupMonacoTheme() { - this.monaco.editor.defineTheme( - gitlabTheme.themeName, - gitlabTheme.monacoTheme, - ); + this.monaco.editor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); this.monaco.editor.setTheme('gitlab'); } @@ -141,6 +154,7 @@ export default class Editor { updateDimensions() { this.instance.layout(); + this.updateDiffView(); } setPosition({ lineNumber, column }) { @@ -157,8 +171,22 @@ export default class Editor { onPositionChange(cb) { if (!this.instance.onDidChangeCursorPosition) return; - this.disposable.add( - this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), - ); + this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e))); + } + + updateDiffView() { + if (!this.isDiffEditorType) return; + + this.instance.updateOptions({ + renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()), + }); + } + + get isDiffEditorType() { + return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; + } + + static renderSideBySide(domElement) { + return domElement.offsetWidth >= 700; } } diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index d69d4b8c615..9f895d49f2e 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -6,6 +6,7 @@ export const defaultEditorOptions = { minimap: { enabled: false, }, + wordWrap: 'on', }; export default [ diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 5f1fb6cf843..a12e637616a 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -20,12 +20,35 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text()); + }, + getBaseRawFileData(file, sha) { + if (file.tempFile) { + return Promise.resolve(file.baseRaw); + } + + if (file.baseRaw) { + return Promise.resolve(file.baseRaw); + } + + return Vue.http + .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { + params: { format: 'json' }, + }) .then(res => res.text()); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequestData(projectId, mergeRequestId) { + return Api.mergeRequest(projectId, mergeRequestId); + }, + getProjectMergeRequestChanges(projectId, mergeRequestId) { + return Api.mergeRequestChanges(projectId, mergeRequestId); + }, + getProjectMergeRequestVersions(projectId, mergeRequestId) { + return Api.mergeRequestVersions(projectId, mergeRequestId); + }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 7e920aa9f30..c6ba679d99c 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -6,8 +6,7 @@ import FilesDecoratorWorker from './workers/files_decorator_worker'; export const redirectToUrl = (_, url) => visitUrl(url); -export const setInitialData = ({ commit }, data) => - commit(types.SET_INITIAL_DATA, data); +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach(file => { @@ -22,7 +21,7 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { }; export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', file.path)); + state.openFiles.forEach(file => dispatch('closeFile', file)); }; export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { @@ -43,14 +42,11 @@ export const createTempEntry = ( ) => new Promise(resolve => { const worker = new FilesDecoratorWorker(); - const fullName = - name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (state.entries[name]) { flash( - `The name "${name - .split('/') - .pop()}" is already taken in this directory.`, + `The name "${name.split('/').pop()}" is already taken in this directory.`, 'alert', document, null, @@ -119,3 +115,4 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => { export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; +export * from './actions/merge_request'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index ddc4b757bf9..1a17320a1ea 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -6,24 +6,34 @@ import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; -export const closeFile = ({ commit, state, getters, dispatch }, path) => { - const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); - const file = state.entries[path]; +export const closeFile = ({ commit, state, dispatch }, file) => { + const path = file.path; + const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key); const fileWasActive = file.active; - commit(types.TOGGLE_FILE_OPEN, path); - commit(types.SET_FILE_ACTIVE, { path, active: false }); + if (file.pending) { + commit(types.REMOVE_PENDING_TAB, file); + } else { + commit(types.TOGGLE_FILE_OPEN, path); + commit(types.SET_FILE_ACTIVE, { path, active: false }); + } if (state.openFiles.length > 0 && fileWasActive) { const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; - const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path]; - - router.push(`/project${nextFileToOpen.url}`); + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + if (nextFileToOpen.pending) { + dispatch('updateViewer', 'diff'); + dispatch('openPendingTab', nextFileToOpen); + } else { + dispatch('updateDelayViewerUpdated', true); + router.push(`/project${nextFileToOpen.url}`); + } } else if (!state.openFiles.length) { router.push(`/project/${file.projectId}/tree/${file.branchId}/`); } - eventHub.$emit(`editor.update.model.dispose.${file.path}`); + eventHub.$emit(`editor.update.model.dispose.${file.key}`); }; export const setFileActive = ({ commit, state, getters, dispatch }, path) => { @@ -46,53 +56,63 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { commit(types.SET_CURRENT_BRANCH, file.branchId); }; -export const getFileData = ({ state, commit, dispatch }, file) => { +export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { + const file = state.entries[path]; commit(types.TOGGLE_LOADING, { entry: file }); - return service .getFileData(file.url) .then(res => { const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - setPageTitle(pageTitle); return res.json(); }) .then(data => { commit(types.SET_FILE_DATA, { data, file }); - commit(types.TOGGLE_FILE_OPEN, file.path); - dispatch('setFileActive', file.path); + commit(types.TOGGLE_FILE_OPEN, path); + if (makeFileActive) dispatch('setFileActive', path); commit(types.TOGGLE_LOADING, { entry: file }); }) .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); - flash( - 'Error loading file data. Please try again.', - 'alert', - document, - null, - false, - true, - ); + flash('Error loading file data. Please try again.', 'alert', document, null, false, true); }); }; -export const getRawFileData = ({ commit, dispatch }, file) => - service - .getRawFileData(file) - .then(raw => { - commit(types.SET_FILE_RAW_DATA, { file, raw }); - }) - .catch(() => - flash( - 'Error loading file content. Please try again.', - 'alert', - document, - null, - false, - true, - ), - ); +export const setFileMrChange = ({ state, commit }, { file, mrChange }) => { + commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange }); +}; + +export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => { + const file = state.entries[path]; + return new Promise((resolve, reject) => { + service + .getRawFileData(file) + .then(raw => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (file.mrChange && file.mrChange.new_file === false) { + service + .getBaseRawFileData(file, baseSha) + .then(baseRaw => { + commit(types.SET_FILE_BASE_RAW_DATA, { + file, + baseRaw, + }); + resolve(raw); + }) + .catch(e => { + reject(e); + }); + } else { + resolve(raw); + } + }) + .catch(() => { + flash('Error loading file content. Please try again.'); + reject(); + }); + }); +}; export const changeFileContent = ({ state, commit }, { path, content }) => { const file = state.entries[path]; @@ -119,10 +139,7 @@ export const setFileEOL = ({ getters, commit }, { eol }) => { } }; -export const setEditorPosition = ( - { getters, commit }, - { editorRow, editorColumn }, -) => { +export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { if (getters.activeFile) { commit(types.SET_FILE_POSITION, { file: getters.activeFile, @@ -132,6 +149,10 @@ export const setEditorPosition = ( } }; +export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { + commit(types.SET_FILE_VIEWMODE, { file, viewMode }); +}; + export const discardFileChanges = ({ state, commit }, path) => { const file = state.entries[path]; @@ -144,3 +165,23 @@ export const discardFileChanges = ({ state, commit }, path) => { eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); }; + +export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { + if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { + return false; + } + + commit(types.ADD_PENDING_TAB, { file }); + + dispatch('scrollToTab'); + + router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); + + return true; +}; + +export const removePendingTab = ({ commit }, file) => { + commit(types.REMOVE_PENDING_TAB, file); + + eventHub.$emit(`editor.update.model.dispose.${file.key}`); +}; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js new file mode 100644 index 00000000000..da73034fd7d --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -0,0 +1,84 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +export const getMergeRequestData = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { + service + .getProjectMergeRequestData(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId, + mergeRequest: data, + }); + if (!state.currentMergeRequestId) { + commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId); + } + resolve(data); + }) + .catch(() => { + flash('Error loading merge request data. Please try again.'); + reject(new Error(`Merge Request not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId]); + } + }); + +export const getMergeRequestChanges = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) { + service + .getProjectMergeRequestChanges(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_CHANGES, { + projectPath: projectId, + mergeRequestId, + changes: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request changes. Please try again.'); + reject(new Error(`Merge Request Changes not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes); + } + }); + +export const getMergeRequestVersions = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId].versions.length || force) { + service + .getProjectMergeRequestVersions(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_VERSIONS, { + projectPath: projectId, + mergeRequestId, + versions: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request versions. Please try again.'); + reject(new Error(`Merge Request Versions not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions); + } + }); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 70a969a0325..6536be04f0a 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -2,9 +2,7 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; import flash from '~/flash'; import service from '../../services'; import * as types from '../mutation_types'; -import { - findEntry, -} from '../utils'; +import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit, dispatch }, path) => { @@ -21,23 +19,24 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('setFileActive', row.path); } else { - dispatch('getFileData', row); + dispatch('getFileData', { path: row.path }); } }; export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { + service + .getTreeLastCommit(tree.lastCommitPath) + .then(res => { const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); return res.json(); }) - .then((data) => { - data.forEach((lastCommit) => { + .then(data => { + data.forEach(lastCommit => { const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); if (entry) { @@ -50,44 +49,47 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); }; -export const getFiles = ( - { state, commit, dispatch }, - { projectId, branchId } = {}, -) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { - const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); - - service - .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then((data) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', (e) => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); - commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ - data, - projectId, - branchId, +export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => + new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then(data => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', e => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch(e => { + flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + reject(e); }); - }) - .catch((e) => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); - reject(e); - }); - } else { - resolve(); - } -}); - + } else { + resolve(); + } + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index eba325a31df..a77cdbc13c8 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,10 +1,8 @@ -export const activeFile = state => - state.openFiles.find(file => file.active) || null; +export const activeFile = state => state.openFiles.find(file => file.active) || null; export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); -export const modifiedFiles = state => - state.changedFiles.filter(f => !f.tempFile); +export const modifiedFiles = state => state.changedFiles.filter(f => !f.tempFile); export const projectsWithTrees = state => Object.keys(state.projects).map(projectId => { @@ -23,8 +21,17 @@ export const projectsWithTrees = state => }; }); +export const currentMergeRequest = state => { + if (state.projects[state.currentProjectId]) { + return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId]; + } + return null; +}; + // eslint-disable-next-line no-confusing-arrow export const currentIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; export const hasChanges = state => !!state.changedFiles.length; + +export const hasMergeRequest = state => !!state.currentMergeRequestId; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e28f190897c..e3f504e5ab0 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,6 +11,12 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +// Merge Request Mutation Types +export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; +export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST'; +export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES'; +export const SET_MERGE_REQUEST_VERSIONS = 'SET_MERGE_REQUEST_VERSIONS'; + // Branch Mutation Types export const SET_BRANCH = 'SET_BRANCH'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; @@ -28,9 +34,11 @@ export const SET_FILE_DATA = 'SET_FILE_DATA'; export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; @@ -39,5 +47,9 @@ export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; export const SET_ENTRIES = 'SET_ENTRIES'; export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; + +export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; +export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index da41fc9285c..5e5eb831662 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import projectMutations from './mutations/project'; +import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -11,10 +12,7 @@ export default { [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) { if (entry.path) { Object.assign(state.entries[entry.path], { - loading: - forceValue !== undefined - ? forceValue - : !state.entries[entry.path].loading, + loading: forceValue !== undefined ? forceValue : !state.entries[entry.path].loading, }); } else { Object.assign(entry, { @@ -83,9 +81,7 @@ export default { if (!foundEntry) { Object.assign(state.trees[`${projectId}/${branchId}`], { - tree: state.trees[`${projectId}/${branchId}`].tree.concat( - data.treeList, - ), + tree: state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList), }); } }, @@ -100,6 +96,7 @@ export default { }); }, ...projectMutations, + ...mergeRequestMutation, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 2500f13db7c..6a143e518f9 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -5,6 +5,14 @@ export default { Object.assign(state.entries[path], { active, }); + + if (active && !state.entries[path].pending) { + Object.assign(state, { + openFiles: state.openFiles.map(f => + Object.assign(f, { active: f.pending ? false : f.active }), + ), + }); + } }, [types.TOGGLE_FILE_OPEN](state, path) { Object.assign(state.entries[path], { @@ -12,10 +20,14 @@ export default { }); if (state.entries[path].opened) { - state.openFiles.push(state.entries[path]); + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]), + }); } else { + const file = state.entries[path]; + Object.assign(state, { - openFiles: state.openFiles.filter(f => f.path !== path), + openFiles: state.openFiles.filter(f => f.key !== file.key), }); } }, @@ -28,6 +40,9 @@ export default { rawPath: data.raw_path, binary: data.binary, renderError: data.render_error, + raw: null, + baseRaw: null, + html: data.html, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -35,6 +50,11 @@ export default { raw, }); }, + [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { + Object.assign(state.entries[file.path], { + baseRaw, + }); + }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { const changed = content !== state.entries[path].raw; @@ -59,6 +79,16 @@ export default { editorColumn, }); }, + [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { + Object.assign(state.entries[file.path], { + mrChange, + }); + }, + [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { + Object.assign(state.entries[file.path], { + viewMode, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, @@ -80,4 +110,37 @@ export default { changed, }); }, + [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { + const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); + let openFiles = state.openFiles.map(f => + Object.assign(f, { active: f.path === file.path, opened: false }), + ); + + if (!pendingTab) { + const openFile = openFiles.find(f => f.path === file.path); + + openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => { + if (!f) return acc; + + if (f.path === file.path) { + return acc.concat({ + ...f, + active: true, + pending: true, + opened: true, + key: `${keyPrefix}-${f.key}`, + }); + } + + return acc.concat(f); + }, []); + } + + Object.assign(state, { openFiles }); + }, + [types.REMOVE_PENDING_TAB](state, file) { + Object.assign(state, { + openFiles: state.openFiles.filter(f => f.key !== file.key), + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js new file mode 100644 index 00000000000..334819fe702 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js @@ -0,0 +1,33 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_MERGE_REQUEST](state, currentMergeRequestId) { + Object.assign(state, { + currentMergeRequestId, + }); + }, + [types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) { + Object.assign(state.projects[projectPath], { + mergeRequests: { + [mergeRequestId]: { + ...mergeRequest, + active: true, + changes: [], + versions: [], + baseCommitSha: null, + }, + }, + }); + }, + [types.SET_MERGE_REQUEST_CHANGES](state, { projectPath, mergeRequestId, changes }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + changes, + }); + }, + [types.SET_MERGE_REQUEST_VERSIONS](state, { projectPath, mergeRequestId, versions }) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + versions, + baseCommitSha: versions.length ? versions[0].base_commit_sha : null, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 2816562a919..284b39a2c72 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -11,6 +11,7 @@ export default { Object.assign(project, { tree: [], branches: {}, + mergeRequests: {}, active: true, }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 6110f54951c..e5cc8814000 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,6 +1,7 @@ export default () => ({ currentProjectId: '', currentBranchId: '', + currentMergeRequestId: '', changedFiles: [], endpoints: {}, lastCommitMsg: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 487ea1ead8e..4befcc501ef 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,7 @@ export const dataStructure = () => ({ id: '', + // Key will contain a mixture of ID and path + // it can also contain a prefix `pending-` for files opened in review mode key: '', type: '', projectId: '', @@ -36,9 +38,11 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', + viewMode: 'edit', + previewMode: null, }); -export const decorateData = (entity) => { +export const decorateData = entity => { const { id, projectId, @@ -55,9 +59,9 @@ export const decorateData = (entity) => { changed = false, parentTreeUrl = '', base64 = false, - + previewMode, file_lock, - + html, } = entity; return { @@ -78,19 +82,18 @@ export const decorateData = (entity) => { renderError, content, base64, - + previewMode, file_lock, - + html, }; }; -export const findEntry = (tree, type, name, prop = 'name') => tree.find( - f => f.type === type && f[prop] === name, -); +export const findEntry = (tree, type, name, prop = 'name') => + tree.find(f => f.type === type && f[prop] === name); export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); -export const setPageTitle = (title) => { +export const setPageTitle = title => { document.title = title; }; @@ -120,6 +123,11 @@ const sortTreesByTypeAndName = (a, b) => { return 0; }; -export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { - tree: entity.tree.length ? sortTree(entity.tree) : [], -})).sort(sortTreesByTypeAndName); +export const sortTree = sortedTree => + sortedTree + .map(entity => + Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], + }), + ) + .sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js index a4cd1ab099f..a1673276900 100644 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -1,14 +1,8 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../utils'; self.addEventListener('message', e => { - const { - data, - projectId, - branchId, - tempFile = false, - content = '', - base64 = false, - } = e.data; + const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; const treeList = []; let file; @@ -19,9 +13,7 @@ self.addEventListener('message', e => { if (pathSplit.length > 0) { pathSplit.reduce((pathAcc, folderName) => { const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${ - parentFolder ? `${parentFolder.path}/` : '' - }${folderName}`; + const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; const foundEntry = acc[folderPath]; if (!foundEntry) { @@ -33,9 +25,7 @@ self.addEventListener('message', e => { path: folderPath, url: `/${projectId}/tree/${branchId}/${folderPath}/`, type: 'tree', - parentTreeUrl: parentFolder - ? parentFolder.url - : `/${projectId}/tree/${branchId}/`, + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -70,13 +60,12 @@ self.addEventListener('message', e => { path, url: `/${projectId}/blob/${branchId}/${path}`, type: 'blob', - parentTreeUrl: fileFolder - ? fileFolder.url - : `/${projectId}/blob/${branchId}`, + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, base64, + previewMode: viewerInformationForPath(blobName), }); Object.assign(acc, { diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue index a6819aaeb12..dfe87d89a39 100644 --- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue +++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue @@ -11,11 +11,19 @@ type: String, required: true, }, + helpUrl: { + type: String, + required: false, + default: '', + }, }, computed: { hasTitle() { return this.title.length > 0; }, + hasHelpURL() { + return this.helpUrl.length > 0; + }, }, }; </script> @@ -28,5 +36,21 @@ {{ title }}: </span> {{ value }} + + <span + v-if="hasHelpURL" + class="help-button pull-right" + > + <a + :href="helpUrl" + target="_blank" + rel="noopener noreferrer nofollow" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + ></i> + </a> + </span> </p> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 56814a52525..af47056d98f 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -22,6 +22,11 @@ type: Boolean, required: true, }, + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, }, computed: { shouldRenderContent() { @@ -39,6 +44,21 @@ runnerId() { return `#${this.job.runner.id}`; }, + hasTimeout() { + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; + }, + timeout() { + if (this.job.metadata == null) { + return ''; + } + + let t = this.job.metadata.timeout_human_readable; + if (this.job.metadata.timeout_source !== '') { + t += ` (from ${this.job.metadata.timeout_source})`; + } + + return t; + }, renderBlock() { return this.job.merge_request || this.job.duration || @@ -115,6 +135,13 @@ :value="queued" /> <detail-row + class="js-job-timeout" + v-if="hasTimeout" + title="Timeout" + :help-url="runnerHelpUrl" + :value="timeout" + /> + <detail-row class="js-job-runner" v-if="job.runner" title="Runner" diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 85a88ae409b..656676ead91 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -51,6 +51,7 @@ export default () => { props: { isLoading: this.mediator.state.isLoading, job: this.mediator.store.state.job, + runnerHelpUrl: dataset.runnerHelpUrl, }, }); }, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 0830ebe9e4e..9ff2042475b 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => { export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a266bb6771f..dd17544b656 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -51,7 +51,7 @@ export function removeParams(params) { const url = document.createElement('a'); url.href = window.location.href; - params.forEach((param) => { + params.forEach(param => { url.search = removeParamQueryString(url.search, param); }); @@ -83,3 +83,11 @@ export function refreshCurrentPage() { export function redirectTo(url) { return window.location.assign(url); } + +export function webIDEUrl(route = undefined) { + let returnUrl = `${gon.relative_url_root}/-/ide/`; + if (route) { + returnUrl += `project${route}`; + } + return returnUrl; +} diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index add07c156a4..c749042a14a 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -94,10 +94,10 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${milestone.name}"> + <li data-milestone-id="${_.escape(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> ${_.escape(milestone.title)} </a> @@ -125,7 +125,6 @@ export default class MilestoneSelect { return milestone.id; } }, - isSelected: milestone => milestone.name === selectedMilestone, hidden: () => { $selectBox.hide(); // display:block overrides the hide-collapse rule @@ -137,7 +136,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: (clickEvent) => { @@ -158,6 +157,7 @@ export default class MilestoneSelect { const isMRIndex = (page === page && page === 'projects:merge_requests:index'); const isSelecting = (selected.name !== selectedMilestone); selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 10b3a4d2fee..f5572be5fbf 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,162 +1,155 @@ <script> - import _ from 'underscore'; - import Flash from '../../flash'; - import MonitoringService from '../services/monitoring_service'; - import GraphGroup from './graph_group.vue'; - import Graph from './graph.vue'; - import EmptyState from './empty_state.vue'; - import MonitoringStore from '../stores/monitoring_store'; - import eventHub from '../event_hub'; +import _ from 'underscore'; +import Flash from '../../flash'; +import MonitoringService from '../services/monitoring_service'; +import GraphGroup from './graph_group.vue'; +import Graph from './graph.vue'; +import EmptyState from './empty_state.vue'; +import MonitoringStore from '../stores/monitoring_store'; +import eventHub from '../event_hub'; - export default { - components: { - Graph, - GraphGroup, - EmptyState, +export default { + components: { + Graph, + GraphGroup, + EmptyState, + }, + props: { + hasMetrics: { + type: Boolean, + required: false, + default: true, }, - - props: { - hasMetrics: { - type: Boolean, - required: false, - default: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - showPanels: { - type: Boolean, - required: false, - default: true, - }, - forceSmallGraph: { - type: Boolean, - required: false, - default: false, - }, - documentationPath: { - type: String, - required: true, - }, - settingsPath: { - type: String, - required: true, - }, - clustersPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: true, - }, - metricsEndpoint: { - type: String, - required: true, - }, - deploymentEndpoint: { - type: String, - required: false, - default: null, - }, - emptyGettingStartedSvgPath: { - type: String, - required: true, - }, - emptyLoadingSvgPath: { - type: String, - required: true, - }, - emptyNoDataSvgPath: { - type: String, - required: true, - }, - emptyUnableToConnectSvgPath: { - type: String, - required: true, - }, + showLegend: { + type: Boolean, + required: false, + default: true, }, - - data() { - return { - store: new MonitoringStore(), - state: 'gettingStarted', - showEmptyState: true, - updateAspectRatio: false, - updatedAspectRatios: 0, - hoverData: {}, - resizeThrottled: {}, - }; + showPanels: { + type: Boolean, + required: false, + default: true, }, - - created() { - this.service = new MonitoringService({ - metricsEndpoint: this.metricsEndpoint, - deploymentEndpoint: this.deploymentEndpoint, - }); - eventHub.$on('toggleAspectRatio', this.toggleAspectRatio); - eventHub.$on('hoverChanged', this.hoverChanged); + forceSmallGraph: { + type: Boolean, + required: false, + default: false, }, - - beforeDestroy() { - eventHub.$off('toggleAspectRatio', this.toggleAspectRatio); - eventHub.$off('hoverChanged', this.hoverChanged); - window.removeEventListener('resize', this.resizeThrottled, false); + documentationPath: { + type: String, + required: true, }, - - mounted() { - this.resizeThrottled = _.throttle(this.resize, 600); - if (!this.hasMetrics) { - this.state = 'gettingStarted'; - } else { - this.getGraphsData(); - window.addEventListener('resize', this.resizeThrottled, false); + settingsPath: { + type: String, + required: true, + }, + clustersPath: { + type: String, + required: true, + }, + tagsPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + metricsEndpoint: { + type: String, + required: true, + }, + deploymentEndpoint: { + type: String, + required: false, + default: null, + }, + emptyGettingStartedSvgPath: { + type: String, + required: true, + }, + emptyLoadingSvgPath: { + type: String, + required: true, + }, + emptyNoDataSvgPath: { + type: String, + required: true, + }, + emptyUnableToConnectSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + store: new MonitoringStore(), + state: 'gettingStarted', + showEmptyState: true, + updateAspectRatio: false, + updatedAspectRatios: 0, + hoverData: {}, + resizeThrottled: {}, + }; + }, + created() { + this.service = new MonitoringService({ + metricsEndpoint: this.metricsEndpoint, + deploymentEndpoint: this.deploymentEndpoint, + }); + eventHub.$on('toggleAspectRatio', this.toggleAspectRatio); + eventHub.$on('hoverChanged', this.hoverChanged); + }, + beforeDestroy() { + eventHub.$off('toggleAspectRatio', this.toggleAspectRatio); + eventHub.$off('hoverChanged', this.hoverChanged); + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + this.resizeThrottled = _.throttle(this.resize, 600); + if (!this.hasMetrics) { + this.state = 'gettingStarted'; + } else { + this.getGraphsData(); + window.addEventListener('resize', this.resizeThrottled, false); + } + }, + methods: { + getGraphsData() { + this.state = 'loading'; + Promise.all([ + this.service.getGraphsData().then(data => this.store.storeMetrics(data)), + this.service + .getDeploymentData() + .then(data => this.store.storeDeploymentData(data)) + .catch(() => new Flash('Error getting deployment information.')), + ]) + .then(() => { + if (this.store.groups.length < 1) { + this.state = 'noData'; + return; + } + this.showEmptyState = false; + }) + .catch(() => { + this.state = 'unableToConnect'; + }); + }, + resize() { + this.updateAspectRatio = true; + }, + toggleAspectRatio() { + this.updatedAspectRatios = this.updatedAspectRatios += 1; + if (this.store.getMetricsCount() === this.updatedAspectRatios) { + this.updateAspectRatio = !this.updateAspectRatio; + this.updatedAspectRatios = 0; } }, - - methods: { - getGraphsData() { - this.state = 'loading'; - Promise.all([ - this.service.getGraphsData() - .then(data => this.store.storeMetrics(data)), - this.service.getDeploymentData() - .then(data => this.store.storeDeploymentData(data)) - .catch(() => new Flash('Error getting deployment information.')), - ]) - .then(() => { - if (this.store.groups.length < 1) { - this.state = 'noData'; - return; - } - this.showEmptyState = false; - }) - .catch(() => { this.state = 'unableToConnect'; }); - }, - - resize() { - this.updateAspectRatio = true; - }, - - toggleAspectRatio() { - this.updatedAspectRatios = this.updatedAspectRatios += 1; - if (this.store.getMetricsCount() === this.updatedAspectRatios) { - this.updateAspectRatio = !this.updateAspectRatio; - this.updatedAspectRatios = 0; - } - }, - - hoverChanged(data) { - this.hoverData = data; - }, + hoverChanged(data) { + this.hoverData = data; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index fbf451fce68..c77f451c2d3 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,91 +1,90 @@ <script> - export default { - props: { - documentationPath: { - type: String, - required: true, - }, - settingsPath: { - type: String, - required: false, - default: '', - }, - clustersPath: { - type: String, - required: false, - default: '', - }, - selectedState: { - type: String, - required: true, - }, - emptyGettingStartedSvgPath: { - type: String, - required: true, - }, - emptyLoadingSvgPath: { - type: String, - required: true, - }, - emptyNoDataSvgPath: { - type: String, - required: true, - }, - emptyUnableToConnectSvgPath: { - type: String, - required: true, - }, +export default { + props: { + documentationPath: { + type: String, + required: true, + }, + settingsPath: { + type: String, + required: false, + default: '', + }, + clustersPath: { + type: String, + required: false, + default: '', + }, + selectedState: { + type: String, + required: true, }, - data() { - return { - states: { - gettingStarted: { - svgUrl: this.emptyGettingStartedSvgPath, - title: 'Get started with performance monitoring', - description: `Stay updated about the performance and health + emptyGettingStartedSvgPath: { + type: String, + required: true, + }, + emptyLoadingSvgPath: { + type: String, + required: true, + }, + emptyNoDataSvgPath: { + type: String, + required: true, + }, + emptyUnableToConnectSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + states: { + gettingStarted: { + svgUrl: this.emptyGettingStartedSvgPath, + title: 'Get started with performance monitoring', + description: `Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.`, - buttonText: 'Install Prometheus on clusters', - buttonPath: this.clustersPath, - secondaryButtonText: 'Configure existing Prometheus', - secondaryButtonPath: this.settingsPath, - }, - loading: { - svgUrl: this.emptyLoadingSvgPath, - title: 'Waiting for performance data', - description: `Creating graphs uses the data from the Prometheus server. + buttonText: 'Install Prometheus on clusters', + buttonPath: this.clustersPath, + secondaryButtonText: 'Configure existing Prometheus', + secondaryButtonPath: this.settingsPath, + }, + loading: { + svgUrl: this.emptyLoadingSvgPath, + title: 'Waiting for performance data', + description: `Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.`, - buttonText: 'View documentation', - buttonPath: this.documentationPath, - }, - noData: { - svgUrl: this.emptyNoDataSvgPath, - title: 'No data found', - description: `You are connected to the Prometheus server, but there is currently + buttonText: 'View documentation', + buttonPath: this.documentationPath, + }, + noData: { + svgUrl: this.emptyNoDataSvgPath, + title: 'No data found', + description: `You are connected to the Prometheus server, but there is currently no data to display.`, - buttonText: 'Configure Prometheus', - buttonPath: this.settingsPath, - }, - unableToConnect: { - svgUrl: this.emptyUnableToConnectSvgPath, - title: 'Unable to connect to Prometheus server', - description: 'Ensure connectivity is available from the GitLab server to the ', - buttonText: 'View documentation', - buttonPath: this.documentationPath, - }, + buttonText: 'Configure Prometheus', + buttonPath: this.settingsPath, + }, + unableToConnect: { + svgUrl: this.emptyUnableToConnectSvgPath, + title: 'Unable to connect to Prometheus server', + description: 'Ensure connectivity is available from the GitLab server to the ', + buttonText: 'View documentation', + buttonPath: this.documentationPath, }, - }; - }, - computed: { - currentState() { - return this.states[this.selectedState]; - }, - - showButtonDescription() { - if (this.selectedState === 'unableToConnect') return true; - return false; }, + }; + }, + computed: { + currentState() { + return this.states[this.selectedState]; + }, + showButtonDescription() { + if (this.selectedState === 'unableToConnect') return true; + return false; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 42615d2bb8e..04d546fafa0 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,236 +1,229 @@ <script> - import { scaleLinear, scaleTime } from 'd3-scale'; - import { axisLeft, axisBottom } from 'd3-axis'; - import { max, extent } from 'd3-array'; - import { select } from 'd3-selection'; - import GraphLegend from './graph/legend.vue'; - import GraphFlag from './graph/flag.vue'; - import GraphDeployment from './graph/deployment.vue'; - import GraphPath from './graph/path.vue'; - import MonitoringMixin from '../mixins/monitoring_mixins'; - import eventHub from '../event_hub'; - import measurements from '../utils/measurements'; - import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; - import createTimeSeries from '../utils/multiple_time_series'; - import bp from '../../breakpoints'; +import { scaleLinear, scaleTime } from 'd3-scale'; +import { axisLeft, axisBottom } from 'd3-axis'; +import { max, extent } from 'd3-array'; +import { select } from 'd3-selection'; +import GraphLegend from './graph/legend.vue'; +import GraphFlag from './graph/flag.vue'; +import GraphDeployment from './graph/deployment.vue'; +import GraphPath from './graph/path.vue'; +import MonitoringMixin from '../mixins/monitoring_mixins'; +import eventHub from '../event_hub'; +import measurements from '../utils/measurements'; +import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; +import createTimeSeries from '../utils/multiple_time_series'; +import bp from '../../breakpoints'; - const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; +const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; - export default { - components: { - GraphLegend, - GraphFlag, - GraphDeployment, - GraphPath, +export default { + components: { + GraphLegend, + GraphFlag, + GraphDeployment, + GraphPath, + }, + mixins: [MonitoringMixin], + props: { + graphData: { + type: Object, + required: true, }, - - mixins: [MonitoringMixin], - - props: { - graphData: { - type: Object, - required: true, - }, - updateAspectRatio: { - type: Boolean, - required: true, - }, - deploymentData: { - type: Array, - required: true, - }, - hoverData: { - type: Object, - required: false, - default: () => ({}), - }, - projectPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - smallGraph: { - type: Boolean, - required: false, - default: false, + updateAspectRatio: { + type: Boolean, + required: true, + }, + deploymentData: { + type: Array, + required: true, + }, + hoverData: { + type: Object, + required: false, + default: () => ({}), + }, + projectPath: { + type: String, + required: true, + }, + tagsPath: { + type: String, + required: true, + }, + showLegend: { + type: Boolean, + required: false, + default: true, + }, + smallGraph: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + baseGraphHeight: 450, + baseGraphWidth: 600, + graphHeight: 450, + graphWidth: 600, + graphHeightOffset: 120, + margin: {}, + unitOfDisplay: '', + yAxisLabel: '', + legendTitle: '', + reducedDeploymentData: [], + measurements: measurements.large, + currentData: { + time: new Date(), + value: 0, }, + currentDataIndex: 0, + currentXCoordinate: 0, + currentFlagPosition: 0, + showFlag: false, + showFlagContent: false, + timeSeries: [], + realPixelRatio: 1, + }; + }, + computed: { + outerViewBox() { + return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; }, - - data() { + innerViewBox() { + return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; + }, + axisTransform() { + return `translate(70, ${this.graphHeight - 100})`; + }, + paddingBottomRootSvg() { return { - baseGraphHeight: 450, - baseGraphWidth: 600, - graphHeight: 450, - graphWidth: 600, - graphHeightOffset: 120, - margin: {}, - unitOfDisplay: '', - yAxisLabel: '', - legendTitle: '', - reducedDeploymentData: [], - measurements: measurements.large, - currentData: { - time: new Date(), - value: 0, - }, - currentDataIndex: 0, - currentXCoordinate: 0, - currentFlagPosition: 0, - showFlag: false, - showFlagContent: false, - timeSeries: [], - realPixelRatio: 1, + paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`, }; }, - - computed: { - outerViewBox() { - return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; - }, - - innerViewBox() { - return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; - }, - - axisTransform() { - return `translate(70, ${this.graphHeight - 100})`; - }, - - paddingBottomRootSvg() { - return { - paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`, - }; - }, - - deploymentFlagData() { - return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); - }, + deploymentFlagData() { + return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); }, - - watch: { - updateAspectRatio() { - if (this.updateAspectRatio) { - this.graphHeight = 450; - this.graphWidth = 600; - this.measurements = measurements.large; - this.draw(); - eventHub.$emit('toggleAspectRatio'); - } - }, - - hoverData() { - this.positionFlag(); - }, + }, + watch: { + updateAspectRatio() { + if (this.updateAspectRatio) { + this.graphHeight = 450; + this.graphWidth = 600; + this.measurements = measurements.large; + this.draw(); + eventHub.$emit('toggleAspectRatio'); + } }, - - mounted() { - this.draw(); + hoverData() { + this.positionFlag(); }, + }, + mounted() { + this.draw(); + }, + methods: { + draw() { + const breakpointSize = bp.getBreakpointSize(); + const query = this.graphData.queries[0]; + this.margin = measurements.large.margin; + if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { + this.graphHeight = 300; + this.margin = measurements.small.margin; + this.measurements = measurements.small; + } + this.unitOfDisplay = query.unit || ''; + this.yAxisLabel = this.graphData.y_label || 'Values'; + this.legendTitle = query.label || 'Average'; + this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; + this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; + this.baseGraphHeight = this.graphHeight; + this.baseGraphWidth = this.graphWidth; - methods: { - draw() { - const breakpointSize = bp.getBreakpointSize(); - const query = this.graphData.queries[0]; - this.margin = measurements.large.margin; - if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { - this.graphHeight = 300; - this.margin = measurements.small.margin; - this.measurements = measurements.small; - } - this.unitOfDisplay = query.unit || ''; - this.yAxisLabel = this.graphData.y_label || 'Values'; - this.legendTitle = query.label || 'Average'; - this.graphWidth = this.$refs.baseSvg.clientWidth - - this.margin.left - this.margin.right; - this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight; - this.baseGraphWidth = this.graphWidth; - - // pixel offsets inside the svg and outside are not 1:1 - this.realPixelRatio = (this.$refs.baseSvg.clientWidth / this.baseGraphWidth); - - this.renderAxesPaths(); - this.formatDeployments(); - }, - - handleMouseOverGraph(e) { - let point = this.$refs.graphData.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x = point.x += 7; - const firstTimeSeries = this.timeSeries[0]; - const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); - const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); - const d0 = firstTimeSeries.values[overlayIndex - 1]; - const d1 = firstTimeSeries.values[overlayIndex]; - if (d0 === undefined || d1 === undefined) return; - const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - const hoveredDataIndex = evalTime ? overlayIndex : (overlayIndex - 1); - const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; - const currentDeployXPos = this.mouseOverDeployInfo(point.x); + // pixel offsets inside the svg and outside are not 1:1 + this.realPixelRatio = this.$refs.baseSvg.clientWidth / this.baseGraphWidth; - eventHub.$emit('hoverChanged', { - hoveredDate, - currentDeployXPos, - }); - }, + this.renderAxesPaths(); + this.formatDeployments(); + }, + handleMouseOverGraph(e) { + let point = this.$refs.graphData.createSVGPoint(); + point.x = e.clientX; + point.y = e.clientY; + point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); + point.x = point.x += 7; + const firstTimeSeries = this.timeSeries[0]; + const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); + const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); + const d0 = firstTimeSeries.values[overlayIndex - 1]; + const d1 = firstTimeSeries.values[overlayIndex]; + if (d0 === undefined || d1 === undefined) return; + const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; + const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1; + const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; + const currentDeployXPos = this.mouseOverDeployInfo(point.x); - renderAxesPaths() { - this.timeSeries = createTimeSeries( - this.graphData.queries, - this.graphWidth, - this.graphHeight, - this.graphHeightOffset, - ); + eventHub.$emit('hoverChanged', { + hoveredDate, + currentDeployXPos, + }); + }, + renderAxesPaths() { + this.timeSeries = createTimeSeries( + this.graphData.queries, + this.graphWidth, + this.graphHeight, + this.graphHeightOffset, + ); - if (!this.showLegend) { - this.baseGraphHeight -= 50; - } else if (this.timeSeries.length > 3) { - this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; - } + if (!this.showLegend) { + this.baseGraphHeight -= 50; + } else if (this.timeSeries.length > 3) { + this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; + } - const axisXScale = d3.scaleTime() - .range([0, this.graphWidth - 70]); - const axisYScale = d3.scaleLinear() - .range([this.graphHeight - this.graphHeightOffset, 0]); + const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); + const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]); - const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); - axisXScale.domain(d3.extent(allValues, d => d.time)); - axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); + const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); + axisXScale.domain(d3.extent(allValues, d => d.time)); + axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - const xAxis = d3.axisBottom() - .scale(axisXScale) - .ticks(this.graphWidth / 120) - .tickFormat(timeScaleFormat); + const xAxis = d3 + .axisBottom() + .scale(axisXScale) + .ticks(this.graphWidth / 120) + .tickFormat(timeScaleFormat); - const yAxis = d3.axisLeft() - .scale(axisYScale) - .ticks(measurements.yTicks); + const yAxis = d3 + .axisLeft() + .scale(axisYScale) + .ticks(measurements.yTicks); - d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis); + d3 + .select(this.$refs.baseSvg) + .select('.x-axis') + .call(xAxis); - const width = this.graphWidth; - d3.select(this.$refs.baseSvg).select('.y-axis').call(yAxis) - .selectAll('.tick') - .each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this).select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } // Avoid adding the class to the first tick, to prevent coloring - }); // This will select all of the ticks once they're rendered - }, + const width = this.graphWidth; + d3 + .select(this.$refs.baseSvg) + .select('.y-axis') + .call(yAxis) + .selectAll('.tick') + .each(function createTickLines(d, i) { + if (i > 0) { + d3 + .select(this) + .select('line') + .attr('x2', width) + .attr('class', 'axis-tick'); + } // Avoid adding the class to the first tick, to prevent coloring + }); // This will select all of the ticks once they're rendered }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue index 98c25307b74..4012191ceb9 100644 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue @@ -1,32 +1,30 @@ <script> - export default { - props: { - deploymentData: { - type: Array, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, +export default { + props: { + deploymentData: { + type: Array, + required: true, }, - - computed: { - calculatedHeight() { - return this.graphHeight - this.graphHeightOffset; - }, + graphHeight: { + type: Number, + required: true, }, - - methods: { - transformDeploymentGroup(deployment) { - return `translate(${Math.floor(deployment.xPos) - 5}, 20)`; - }, + graphHeightOffset: { + type: Number, + required: true, }, - }; + }, + computed: { + calculatedHeight() { + return this.graphHeight - this.graphHeightOffset; + }, + }, + methods: { + transformDeploymentGroup(deployment) { + return `translate(${Math.floor(deployment.xPos) - 5}, 20)`; + }, + }, +}; </script> <template> <g class="deploy-info"> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 07aa6a3e5de..906c7c51f52 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -1,127 +1,119 @@ <script> - import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; - import { formatRelevantDigits } from '../../../lib/utils/number_utils'; - import icon from '../../../vue_shared/components/icon.vue'; +import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; +import { formatRelevantDigits } from '../../../lib/utils/number_utils'; +import icon from '../../../vue_shared/components/icon.vue'; - export default { - components: { - icon, - }, - props: { - currentXCoordinate: { - type: Number, - required: true, - }, - currentData: { - type: Object, - required: true, - }, - deploymentFlagData: { - type: Object, - required: false, - default: null, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - realPixelRatio: { - type: Number, - required: true, - }, - showFlagContent: { - type: Boolean, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - currentDataIndex: { - type: Number, - required: true, - }, - legendTitle: { - type: String, - required: true, - }, +export default { + components: { + icon, + }, + props: { + currentXCoordinate: { + type: Number, + required: true, }, - - computed: { - formatTime() { - return this.deploymentFlagData ? - timeFormat(this.deploymentFlagData.time) : - timeFormat(this.currentData.time); - }, - - formatDate() { - return this.deploymentFlagData ? - dateFormat(this.deploymentFlagData.time) : - dateFormat(this.currentData.time); - }, - - cursorStyle() { - const xCoordinate = this.deploymentFlagData ? - this.deploymentFlagData.xPos : - this.currentXCoordinate; - - const offsetTop = 20 * this.realPixelRatio; - const offsetLeft = (70 + xCoordinate) * this.realPixelRatio; - const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio; - - return { - top: `${offsetTop}px`, - left: `${offsetLeft}px`, - height: `${height}px`, - }; - }, - - flagOrientation() { - if (this.currentXCoordinate * this.realPixelRatio > 120) { - return 'left'; - } - return 'right'; - }, + currentData: { + type: Object, + required: true, }, + deploymentFlagData: { + type: Object, + required: false, + default: null, + }, + graphHeight: { + type: Number, + required: true, + }, + graphHeightOffset: { + type: Number, + required: true, + }, + realPixelRatio: { + type: Number, + required: true, + }, + showFlagContent: { + type: Boolean, + required: true, + }, + timeSeries: { + type: Array, + required: true, + }, + unitOfDisplay: { + type: String, + required: true, + }, + currentDataIndex: { + type: Number, + required: true, + }, + legendTitle: { + type: String, + required: true, + }, + }, + computed: { + formatTime() { + return this.deploymentFlagData + ? timeFormat(this.deploymentFlagData.time) + : timeFormat(this.currentData.time); + }, + formatDate() { + return this.deploymentFlagData + ? dateFormat(this.deploymentFlagData.time) + : dateFormat(this.currentData.time); + }, + cursorStyle() { + const xCoordinate = this.deploymentFlagData + ? this.deploymentFlagData.xPos + : this.currentXCoordinate; - methods: { - seriesMetricValue(series) { - const index = this.deploymentFlagData ? - this.deploymentFlagData.seriesIndex : - this.currentDataIndex; - const value = series.values[index] && - series.values[index].value; - if (isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; - }, - - seriesMetricLabel(index, series) { - if (this.timeSeries.length < 2) { - return this.legendTitle; - } - if (series.metricTag) { - return series.metricTag; - } - return `series ${index + 1}`; - }, + const offsetTop = 20 * this.realPixelRatio; + const offsetLeft = (70 + xCoordinate) * this.realPixelRatio; + const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio; - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; - }, + return { + top: `${offsetTop}px`, + left: `${offsetLeft}px`, + height: `${height}px`, + }; + }, + flagOrientation() { + if (this.currentXCoordinate * this.realPixelRatio > 120) { + return 'left'; + } + return 'right'; + }, + }, + methods: { + seriesMetricValue(series) { + const index = this.deploymentFlagData + ? this.deploymentFlagData.seriesIndex + : this.currentDataIndex; + const value = series.values[index] && series.values[index].value; + if (isNaN(value)) { + return '-'; + } + return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; + }, + seriesMetricLabel(index, series) { + if (this.timeSeries.length < 2) { + return this.legendTitle; + } + if (series.metricTag) { + return series.metricTag; + } + return `series ${index + 1}`; + }, + strokeDashArray(type) { + if (type === 'dashed') return '6, 3'; + if (type === 'dotted') return '3, 3'; + return null; }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index 3149397b61f..a7a058a9203 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -1,127 +1,119 @@ <script> - import { formatRelevantDigits } from '../../../lib/utils/number_utils'; - - export default { - props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, - legendTitle: { - type: String, - required: true, - }, - yAxisLabel: { - type: String, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - currentDataIndex: { - type: Number, - required: true, - }, - showLegendGroup: { - type: Boolean, - required: false, - default: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - seriesXPosition: 0, - metricUsageXPosition: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = (((this.graphHeight - this.margin.top) - + this.measurements.axisLabelLineOffset) / 2) || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - - rectTransform() { - const yCoordinate = (((this.graphHeight - this.margin.top) - + this.measurements.axisLabelLineOffset) / 2) - + (this.yLabelWidth / 2) || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - - xPosition() { - return (((this.graphWidth + this.measurements.axisLabelLineOffset) / 2) - - this.margin.right) || 0; - }, - - yPosition() { - return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0; - }, +import { formatRelevantDigits } from '../../../lib/utils/number_utils'; +export default { + props: { + graphWidth: { + type: Number, + required: true, }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.metricUsageXPosition = 0; - this.seriesXPosition = 0; - if (this.$refs.legendTitleSvg != null) { - this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; - } - if (this.$refs.seriesTitleSvg != null) { - this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; - } - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); - }, - methods: { - translateLegendGroup(index) { - return `translate(0, ${12 * (index)})`; - }, - - formatMetricUsage(series) { - const value = series.values[this.currentDataIndex] && - series.values[this.currentDataIndex].value; - if (isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; - }, + graphHeight: { + type: Number, + required: true, + }, + margin: { + type: Object, + required: true, + }, + measurements: { + type: Object, + required: true, + }, + legendTitle: { + type: String, + required: true, + }, + yAxisLabel: { + type: String, + required: true, + }, + timeSeries: { + type: Array, + required: true, + }, + unitOfDisplay: { + type: String, + required: true, + }, + currentDataIndex: { + type: Number, + required: true, + }, + showLegendGroup: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + yLabelWidth: 0, + yLabelHeight: 0, + seriesXPosition: 0, + metricUsageXPosition: 0, + }; + }, + computed: { + textTransform() { + const yCoordinate = + (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - createSeriesString(index, series) { - if (series.metricTag) { - return `${series.metricTag} ${this.formatMetricUsage(series)}`; - } - return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; - }, + return `translate(15, ${yCoordinate}) rotate(-90)`; + }, + rectTransform() { + const yCoordinate = + (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + + this.yLabelWidth / 2 || 0; - strokeDashArray(type) { - if (type === 'dashed') return '6, 3'; - if (type === 'dotted') return '3, 3'; - return null; - }, + return `translate(0, ${yCoordinate}) rotate(-90)`; + }, + xPosition() { + return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; + }, + yPosition() { + return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; + }, + }, + mounted() { + this.$nextTick(() => { + const bbox = this.$refs.ylabel.getBBox(); + this.metricUsageXPosition = 0; + this.seriesXPosition = 0; + if (this.$refs.legendTitleSvg != null) { + this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; + } + if (this.$refs.seriesTitleSvg != null) { + this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; + } + this.yLabelWidth = bbox.width + 10; // Added some padding + this.yLabelHeight = bbox.height + 5; + }); + }, + methods: { + translateLegendGroup(index) { + return `translate(0, ${12 * index})`; + }, + formatMetricUsage(series) { + const value = + series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value; + if (isNaN(value)) { + return '-'; + } + return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; + }, + createSeriesString(index, series) { + if (series.metricTag) { + return `${series.metricTag} ${this.formatMetricUsage(series)}`; + } + return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; + }, + strokeDashArray(type) { + if (type === 'dashed') return '6, 3'; + if (type === 'dotted') return '3, 3'; + return null; }, - }; + }, +}; </script> <template> <g class="axis-label-container"> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue index c9721c4cb01..881560124a5 100644 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ b/app/assets/javascripts/monitoring/components/graph/path.vue @@ -1,36 +1,36 @@ <script> - export default { - props: { - generatedLinePath: { - type: String, - required: true, - }, - generatedAreaPath: { - type: String, - required: true, - }, - lineStyle: { - type: String, - required: false, - default: '', - }, - lineColor: { - type: String, - required: true, - }, - areaColor: { - type: String, - required: true, - }, +export default { + props: { + generatedLinePath: { + type: String, + required: true, }, - computed: { - strokeDashArray() { - if (this.lineStyle === 'dashed') return '3, 1'; - if (this.lineStyle === 'dotted') return '1, 1'; - return null; - }, + generatedAreaPath: { + type: String, + required: true, }, - }; + lineStyle: { + type: String, + required: false, + default: '', + }, + lineColor: { + type: String, + required: true, + }, + areaColor: { + type: String, + required: true, + }, + }, + computed: { + strokeDashArray() { + if (this.lineStyle === 'dashed') return '3, 1'; + if (this.lineStyle === 'dotted') return '1, 1'; + return null; + }, + }, +}; </script> <template> <g> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index f71cf614552..a6dbe42a8f0 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -1,17 +1,17 @@ <script> - export default { - props: { - name: { - type: String, - required: true, - }, - showPanels: { - type: Boolean, - required: false, - default: true, - }, +export default { + props: { + name: { + type: String, + required: true, }, - }; + showPanels: { + type: Boolean, + required: false, + default: true, + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 09f0ea37103..ac70ddb3ff4 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1190,12 +1190,12 @@ export default class Notes { addForm = false; let lineTypeSelector = ''; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content discussion-notes"></div></td></tr>'; // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineTypeSelector = `.${lineType}`; rowCssToAdd = - '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; + '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content discussion-notes"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content discussion-notes"></div></td></tr>'; } const notesContentSelector = `.notes_content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); @@ -1809,9 +1809,11 @@ export default class Notes { } } + $closeBtn.text($closeBtn.data('originalText')); + /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios + return axios .post(`${formAction}?html=true`, formData) .then(res => { const note = res.data; @@ -1928,8 +1930,6 @@ export default class Notes { this.reenableTargetFormSubmitButton(e); this.addNoteError($form); }); - - return $closeBtn.text($closeBtn.data('originalText')); } /** diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 90dcafd75b7..648fa6ff804 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -99,6 +99,10 @@ export default { 'js-note-target-reopen': !this.isOpen, }; }, + supportQuickActions() { + // Disable quick actions support for Epics + return this.noteableType !== constants.EPIC_NOTEABLE_TYPE; + }, markdownDocsPath() { return this.getNotesData.markdownDocsPath; }, @@ -355,7 +359,7 @@ Please check your network connection and try again.`; name="note[note]" class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" - data-supports-quick-actions="true" + :data-supports-quick-actions="supportQuickActions" aria-label="Description" v-model="note" ref="textarea" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index cf579c5d4dc..476b15aca4a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -258,9 +258,7 @@ Please check your network connection and try again.`; :key="note.id" /> </ul> - <div - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> + <div class="discussion-reply-holder"> <template v-if="!isReplying && canReply"> <div class="btn-group-justified discussion-with-resolve-btn" @@ -292,10 +290,12 @@ Please check your network connection and try again.`; </button> </div> <div + v-if="note.resolvable" class="btn-group discussion-actions" - role="group"> + role="group" + > <div - v-if="note.resolvable && !discussionResolved" + v-if="!discussionResolved" class="btn-group" role="group"> <a diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index a90c6d6381d..5bd81c7cad6 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -50,7 +50,11 @@ export default { ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), noteableType() { // FIXME -- @fatihacet Get this from JSON data. - const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants; + + if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) { + return EPIC_NOTEABLE_TYPE; + } return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index f4f407ffd8a..68f8cb1cf1e 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -10,6 +10,7 @@ export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const EPIC_NOTEABLE_TYPE = 'epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index f90775d0157..e4121f151db 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -12,8 +12,11 @@ document.addEventListener( data() { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); let currentUserData = {}; + noteableData.noteableType = notesDataset.noteableType; + if (parsedUserData) { currentUserData = { id: parsedUserData.id, @@ -25,7 +28,7 @@ document.addEventListener( } return { - noteableData: JSON.parse(notesDataset.noteableData), + noteableData, currentUserData, notesData: JSON.parse(notesDataset.notesData), }; diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index 0da4ff49f08..5bf8216a1f3 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -14,6 +14,8 @@ export default { return constants.MERGE_REQUEST_NOTEABLE_TYPE; case 'Issue': return constants.ISSUE_NOTEABLE_TYPE; + case 'Epic': + return constants.EPIC_NOTEABLE_TYPE; default: return ''; } diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js new file mode 100644 index 00000000000..48d75f5443b --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -0,0 +1,6 @@ +import initSettingsPanels from '~/settings_panels'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); +}); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index d149b307e7f..914f804fdd3 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index a5cc1f34b63..1600faa3611 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, + isGroupDecendent: true, }); projectSelect(); }); diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index 22248418c41..2bda2aeb3a1 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -19,15 +19,19 @@ type: String, required: true, }, + groupName: { + type: String, + required: true, + }, }, computed: { title() { return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle }); }, text() { - return s__(`Milestones|Promoting this milestone will make it available for all projects inside the group. + return sprintf(s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}. Existing project milestones with the same title will be merged. - This action cannot be reversed.`); + This action cannot be reversed.`), { milestoneTitle: this.milestoneTitle, groupName: this.groupName }); }, }, methods: { diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js index d00f81c9094..8e79341e96a 100644 --- a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js +++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js @@ -25,6 +25,7 @@ export default () => { const modalProps = { milestoneTitle: button.dataset.milestoneTitle, url: button.dataset.url, + groupName: button.dataset.groupName, }; eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted); eventHub.$emit('promoteMilestoneModal.props', modalProps); @@ -54,6 +55,7 @@ export default () => { return { modalProps: { milestoneTitle: '', + groupName: '', url: '', }, }; diff --git a/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js index 9ab73be80a0..9ab73be80a0 100644 --- a/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js +++ b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js diff --git a/app/assets/javascripts/pages/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js index 8e8a843da0b..8e8a843da0b 100644 --- a/app/assets/javascripts/pages/ci/lints/new/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/new/index.js diff --git a/app/assets/javascripts/pages/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 8e8a843da0b..8e8a843da0b 100644 --- a/app/assets/javascripts/pages/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index 54695dfeb99..ad6df51bb7a 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -1,4 +1,5 @@ <script> + import _ from 'underscore'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import GlModal from '~/vue_shared/components/gl_modal.vue'; @@ -27,19 +28,26 @@ type: String, required: true, }, + groupName: { + type: String, + required: true, + }, }, computed: { text() { - return s__(`Milestones|Promoting this label will make it available for all projects inside the group. - Existing project labels with the same title will be merged. This action cannot be reversed.`); + return sprintf(s__(`Labels|Promoting %{labelTitle} will make it available for all projects inside %{groupName}. + Existing project labels with the same title will be merged. This action cannot be reversed.`), { + labelTitle: this.labelTitle, + groupName: this.groupName, + }); }, title() { const label = `<span class="label color-label" style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" - >${this.labelTitle}</span>`; + >${_.escape(this.labelTitle)}</span>`; - return sprintf(s__('Labels|Promote label %{labelTitle} to Group Label?'), { + return sprintf(s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'), { labelTitle: label, }, false); }, @@ -69,6 +77,7 @@ > <div slot="title" + class="modal-title-with-label" v-html="title" > {{ title }} diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 2abcbfab1ed..03cfef61311 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -30,6 +30,7 @@ const initLabelIndex = () => { labelColor: button.dataset.labelColor, labelTextColor: button.dataset.labelTextColor, url: button.dataset.url, + groupName: button.dataset.groupName, }; eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); eventHub.$emit('promoteLabelModal.props', modalProps); @@ -62,6 +63,7 @@ const initLabelIndex = () => { labelColor: '', labelTextColor: '', url: '', + groupName: '', }, }; }, diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 8a86c409b62..ceb02309959 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,59 +1,73 @@ <script> - import Flash from '../../../flash'; - import editForm from './edit_form.vue'; - import Icon from '../../../vue_shared/components/icon.vue'; - import { __ } from '../../../locale'; +import Flash from '../../../flash'; +import editForm from './edit_form.vue'; +import Icon from '../../../vue_shared/components/icon.vue'; +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, +export default { + components: { + editForm, + Icon, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - isEditable: { - required: true, - type: Boolean, - }, - service: { - required: true, - type: Object, - }, + isEditable: { + required: true, + type: Boolean, }, - data() { - return { - edit: false, - }; + service: { + required: true, + type: Object, }, - computed: { - confidentialityIcon() { - return this.isConfidential ? 'eye-slash' : 'eye'; - }, + }, + data() { + return { + edit: false, + }; + }, + computed: { + confidentialityIcon() { + return this.isConfidential ? 'eye-slash' : 'eye'; }, - methods: { - toggleForm() { - this.edit = !this.edit; - }, - updateConfidentialAttribute(confidential) { - this.service.update('issue', { confidential }) - .then(() => location.reload()) - .catch(() => { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); - }); - }, + }, + created() { + eventHub.$on('closeConfidentialityForm', this.toggleForm); + }, + beforeDestroy() { + eventHub.$off('closeConfidentialityForm', this.toggleForm); + }, + methods: { + toggleForm() { + this.edit = !this.edit; }, - }; + updateConfidentialAttribute(confidential) { + this.service + .update('issue', { confidential }) + .then(() => location.reload()) + .catch(() => { + Flash( + __( + 'Something went wrong trying to change the confidentiality of this issue', + ), + ); + }); + }, + }, +}; </script> <template> <div class="block issuable-sidebar-item confidentiality"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="confidentialityIcon" - :size="16" aria-hidden="true" /> </div> @@ -71,7 +85,6 @@ <div class="value sidebar-item-value hide-collapsed"> <editForm v-if="edit" - :toggle-form="toggleForm" :is-confidential="isConfidential" :update-confidential-attribute="updateConfidentialAttribute" /> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index c569843b05f..3783f71a848 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,34 +1,34 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import { s__ } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import { s__ } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + props: { + isConfidential: { + required: true, + type: Boolean, }, - props: { - isConfidential: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - updateConfidentialAttribute: { - required: true, - type: Function, - }, + updateConfidentialAttribute: { + required: true, + type: Function, }, - computed: { - confidentialityOnWarning() { - return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.'); - }, - confidentialityOffWarning() { - return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.'); - }, + }, + computed: { + confidentialityOnWarning() { + return s__( + 'confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.', + ); }, - }; + confidentialityOffWarning() { + return s__( + 'confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.', + ); + }, + }, +}; </script> <template> @@ -45,7 +45,6 @@ </p> <edit-form-buttons :is-confidential="isConfidential" - :toggle-form="toggleForm" :update-confidential-attribute="updateConfidentialAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 49d5dfeea1a..38b1ddbfd5b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,14 +1,13 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isConfidential: { required: true, type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, updateConfidentialAttribute: { required: true, type: Function, @@ -22,6 +21,16 @@ export default { return !this.isConfidential; }, }, + methods: { + closeForm() { + eventHub.$emit('closeConfidentialityForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateConfidentialAttribute(this.updateConfidentialBool); + }, + }, }; </script> @@ -30,14 +39,14 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> <button type="button" class="btn btn-close" - @click.prevent="updateConfidentialAttribute(updateConfidentialBool)" + @click.prevent="submitForm" > {{ toggleButtonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index bc32e974bc3..e1e4715826a 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,40 +1,43 @@ <script> - import editFormButtons from './edit_form_buttons.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import { __, sprintf } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import { __, sprintf } from '../../../locale'; - export default { - components: { - editFormButtons, +export default { + components: { + editFormButtons, + }, + mixins: [issuableMixin], + props: { + isLocked: { + required: true, + type: Boolean, }, - mixins: [ - issuableMixin, - ], - props: { - isLocked: { - required: true, - type: Boolean, - }, - toggleForm: { - required: true, - type: Function, - }, - - updateLockedAttribute: { - required: true, - type: Function, - }, + updateLockedAttribute: { + required: true, + type: Function, + }, + }, + computed: { + lockWarning() { + return sprintf( + __( + 'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - computed: { - lockWarning() { - return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, - unlockWarning() { - return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); - }, + unlockWarning() { + return sprintf( + __( + 'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.', + ), + { issuableDisplayName: this.issuableDisplayName }, + ); }, - }; + }, +}; </script> <template> @@ -54,7 +57,6 @@ <edit-form-buttons :is-locked="isLocked" - :toggle-form="toggleForm" :update-locked-attribute="updateLockedAttribute" /> </div> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index c3a553a7605..5e7b8f9698f 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,4 +1,7 @@ <script> +import $ from 'jquery'; +import eventHub from '../../event_hub'; + export default { props: { isLocked: { @@ -6,11 +9,6 @@ export default { type: Boolean, }, - toggleForm: { - required: true, - type: Function, - }, - updateLockedAttribute: { required: true, type: Function, @@ -26,6 +24,17 @@ export default { return !this.isLocked; }, }, + + methods: { + closeForm() { + eventHub.$emit('closeLockForm'); + $(this.$el).trigger('hidden.gl.dropdown'); + }, + submitForm() { + this.closeForm(); + this.updateLockedAttribute(this.toggleLock); + }, + }, }; </script> @@ -34,7 +43,7 @@ export default { <button type="button" class="btn btn-default append-right-10" - @click="toggleForm" + @click="closeForm" > {{ __('Cancel') }} </button> @@ -42,7 +51,7 @@ export default { <button type="button" class="btn btn-close" - @click.prevent="updateLockedAttribute(toggleLock)" + @click.prevent="submitForm" > {{ buttonText }} </button> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 0686910fc7e..e4893451af3 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,70 +1,93 @@ <script> - import Flash from '~/flash'; - import editForm from './edit_form.vue'; - import issuableMixin from '../../../vue_shared/mixins/issuable'; - import Icon from '../../../vue_shared/components/icon.vue'; +import Flash from '~/flash'; +import editForm from './edit_form.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; +import Icon from '../../../vue_shared/components/icon.vue'; +import eventHub from '../../event_hub'; - export default { - components: { - editForm, - Icon, - }, - mixins: [ - issuableMixin, - ], +export default { + components: { + editForm, + Icon, + }, + mixins: [issuableMixin], - props: { - isLocked: { - required: true, - type: Boolean, - }, + props: { + isLocked: { + required: true, + type: Boolean, + }, - isEditable: { - required: true, - type: Boolean, - }, + isEditable: { + required: true, + type: Boolean, + }, - mediator: { - required: true, - type: Object, - validator(mediatorObject) { - return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; - }, + mediator: { + required: true, + type: Object, + validator(mediatorObject) { + return ( + mediatorObject.service && + mediatorObject.service.update && + mediatorObject.store + ); }, }, + }, - computed: { - lockIcon() { - return this.isLocked ? 'lock' : 'lock-open'; - }, + computed: { + lockIcon() { + return this.isLocked ? 'lock' : 'lock-open'; + }, - isLockDialogOpen() { - return this.mediator.store.isLockDialogOpen; - }, + isLockDialogOpen() { + return this.mediator.store.isLockDialogOpen; }, + }, - methods: { - toggleForm() { - this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; - }, + created() { + eventHub.$on('closeLockForm', this.toggleForm); + }, + + beforeDestroy() { + eventHub.$off('closeLockForm', this.toggleForm); + }, - updateLockedAttribute(locked) { - this.mediator.service.update(this.issuableType, { + methods: { + toggleForm() { + this.mediator.store.isLockDialogOpen = !this.mediator.store + .isLockDialogOpen; + }, + + updateLockedAttribute(locked) { + this.mediator.service + .update(this.issuableType, { discussion_locked: locked, }) .then(() => location.reload()) - .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`))); - }, + .catch(() => + Flash( + this.__( + `Something went wrong trying to change the locked state of this ${ + this.issuableDisplayName + }`, + ), + ), + ); }, - }; + }, +}; </script> <template> <div class="block issuable-sidebar-item lock"> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="toggleForm" + > <icon :name="lockIcon" - :size="16" aria-hidden="true" class="sidebar-item-icon is-active" /> @@ -85,7 +108,6 @@ <div class="value sidebar-item-value hide-collapsed"> <edit-form v-if="isLockDialogOpen" - :toggle-form="toggleForm" :is-locked="isLocked" :update-locked-attribute="updateLockedAttribute" :issuable-type="issuableType" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index b5ebccd3795..82c4562f9a9 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,7 +1,8 @@ +<script> import { parseSeconds, stringifyTime } from '../../../lib/utils/pretty_time'; export default { - name: 'time-tracking-comparison-pane', + name: 'TimeTrackingComparisonPane', props: { timeSpent: { type: Number, @@ -43,47 +44,50 @@ export default { return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; }, }, - template: ` - <div class="time-tracking-comparison-pane"> +}; +</script> + +<template> + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > <div - class="compare-meter" - data-toggle="tooltip" - data-placement="top" - role="timeRemainingDisplay" - :aria-valuenow="timeRemainingTooltip" - :title="timeRemainingTooltip" - :data-original-title="timeRemainingTooltip" - :class="timeRemainingStatusClass" + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" > <div - class="meter-container" - role="timeSpentPercent" - :aria-valuenow="timeRemainingPercent" + :style="{ width: timeRemainingPercent }" + class="meter-fill" > - <div - :style="{ width: timeRemainingPercent }" - class="meter-fill" - /> </div> - <div class="compare-display-container"> - <div class="compare-display pull-left"> - <span class="compare-label"> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> {{ s__('TimeTracking|Spent') }} - </span> - <span class="compare-value spent"> - {{ timeSpentHumanReadable }} - </span> - </div> - <div class="compare-display estimated pull-right"> - <span class="compare-label"> - {{ s__('TimeTrackingEstimated|Est') }} - </span> - <span class="compare-value"> - {{ timeEstimateHumanReadable }} - </span> - </div> + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + {{ s__('TimeTrackingEstimated|Est') }} + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> </div> </div> </div> - `, -}; + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 28240468d2c..1c641c73ea3 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -4,7 +4,7 @@ import TimeTrackingCollapsedState from './collapsed_state.vue'; import timeTrackingSpentOnlyPane from './spent_only_pane'; import timeTrackingNoTrackingPane from './no_tracking_pane'; import timeTrackingEstimateOnlyPane from './estimate_only_pane'; -import timeTrackingComparisonPane from './comparison_pane'; +import TimeTrackingComparisonPane from './comparison_pane.vue'; import eventHub from '../../event_hub'; @@ -15,7 +15,7 @@ export default { 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, - 'time-tracking-comparison-pane': timeTrackingComparisonPane, + TimeTrackingComparisonPane, 'time-tracking-help-state': timeTrackingHelpState, }, props: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3d886e7d628..18ee4c62bf1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,53 +1,57 @@ <script> - import tooltip from '~/vue_shared/directives/tooltip'; - import { n__ } from '~/locale'; - import icon from '~/vue_shared/components/icon.vue'; - import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { n__ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; +import icon from '~/vue_shared/components/icon.vue'; +import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; - export default { - name: 'MRWidgetHeader', - directives: { - tooltip, +export default { + name: 'MRWidgetHeader', + directives: { + tooltip, + }, + components: { + icon, + clipboardButton, + }, + props: { + mr: { + type: Object, + required: true, }, - components: { - icon, - clipboardButton, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; }, - props: { - mr: { - type: Object, - required: true, - }, + commitsText() { + return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); }, - computed: { - shouldShowCommitsBehindText() { - return this.mr.divergedCommitsCount > 0; - }, - commitsText() { - return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); - }, - branchNameClipboardData() { - // This supports code in app/assets/javascripts/copy_to_clipboard.js that - // works around ClipboardJS limitations to allow the context-specific - // copy/pasting of plain text or GFM. - return JSON.stringify({ - text: this.mr.sourceBranch, - gfm: `\`${this.mr.sourceBranch}\``, - }); - }, - isSourceBranchLong() { - return this.isBranchTitleLong(this.mr.sourceBranch); - }, - isTargetBranchLong() { - return this.isBranchTitleLong(this.mr.targetBranch); - }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); }, - methods: { - isBranchTitleLong(branchTitle) { - return branchTitle.length > 32; - }, + isSourceBranchLong() { + return this.isBranchTitleLong(this.mr.sourceBranch); }, - }; + isTargetBranchLong() { + return this.isBranchTitleLong(this.mr.targetBranch); + }, + webIdePath() { + return webIDEUrl(this.mr.statusPath.replace('.json', '')); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, +}; </script> <template> <div class="mr-source-target"> @@ -96,6 +100,13 @@ </div> <div v-if="mr.isOpen"> + <a + v-if="!mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-sm btn-default inline js-web-ide" + > + {{ s__("mrWidget|Web IDE") }} + </a> <button data-target="#modal_merge_info" data-toggle="modal" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 04100871a94..7cc07401911 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -17,8 +17,8 @@ export default { /> <div class="media-body space-children"> <span class="bold"> - The source branch HEAD has recently changed. - Please reload the page and review the changes before merging. + {{ s__(`mrWidget|The source branch HEAD has recently changed. +Please reload the page and review the changes before merging`) }} </span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue new file mode 100644 index 00000000000..fb8ccea91c7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -0,0 +1,43 @@ +<script> +import { viewerInformationForPath } from './lib/viewer_utils'; +import MarkdownViewer from './viewers/markdown_viewer.vue'; + +export default { + props: { + content: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + viewer() { + const previewInfo = viewerInformationForPath(this.path); + switch (previewInfo.id) { + case 'markdown': + return MarkdownViewer; + default: + return null; + } + }, + }, +}; +</script> + +<template> + <div class="preview-container"> + <component + :is="viewer" + :project-path="projectPath" + :content="content" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js new file mode 100644 index 00000000000..4f2e1e47dd1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -0,0 +1,23 @@ +const viewers = { + markdown: { + id: 'markdown', + previewTitle: 'Preview Markdown', + }, +}; + +const fileNameViewers = {}; +const fileExtensionViewers = { + md: 'markdown', + markdown: 'markdown', +}; + +export function viewerInformationForPath(path) { + if (!path) return null; + const name = path.split('/').pop(); + const viewerName = + fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || ''; + + return viewers[viewerName]; +} + +export default viewers; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue new file mode 100644 index 00000000000..09e0094054d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -0,0 +1,90 @@ +<script> +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import $ from 'jquery'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + +const CancelToken = axios.CancelToken; +let axiosSource; + +export default { + components: { + SkeletonLoadingContainer, + }, + props: { + content: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + previewContent: null, + isLoading: false, + }; + }, + watch: { + content() { + this.previewContent = null; + }, + }, + created() { + axiosSource = CancelToken.source(); + this.fetchMarkdownPreview(); + }, + updated() { + this.fetchMarkdownPreview(); + }, + destroyed() { + if (this.isLoading) axiosSource.cancel('Cancelling Preview'); + }, + methods: { + fetchMarkdownPreview() { + if (this.content && this.previewContent === null) { + this.isLoading = true; + const postBody = { + text: this.content, + }; + const postOptions = { + cancelToken: axiosSource.token, + }; + + axios + .post( + `${gon.relative_url_root}/${this.projectPath}/preview_markdown`, + postBody, + postOptions, + ) + .then(({ data }) => { + this.previewContent = data.body; + this.isLoading = false; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => { + this.previewContent = __('An error occurred while fetching markdown preview'); + this.isLoading = false; + }); + } + }, + }, +}; +</script> + +<template> + <div + ref="markdown-preview" + class="md md-previewer"> + <skeleton-loading-container v-if="isLoading" /> + <div + v-else + v-html="previewContent"> + </div> + </div> +</template> diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index db36e27fa74..05cb0196ced 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -2,7 +2,15 @@ * Styles the GitLab application with a specific color theme */ -@mixin gitlab-theme($color-100, $color-200, $color-500, $color-700, $color-800, $color-900, $color-alternate) { +@mixin gitlab-theme( + $color-100, + $color-200, + $color-500, + $color-700, + $color-800, + $color-900, + $color-alternate +) { // Header .navbar-gitlab { @@ -23,7 +31,7 @@ > li { > a:hover, > a:focus { - background-color: rgba($color-200, .2); + background-color: rgba($color-200, 0.2); } &.active > a, @@ -33,7 +41,7 @@ } &.line-separator { - border-left: 1px solid rgba($color-200, .2); + border-left: 1px solid rgba($color-200, 0.2); } } } @@ -56,7 +64,7 @@ &:hover, &:focus { @media (min-width: $screen-sm-min) { - background-color: rgba($color-200, .2); + background-color: rgba($color-200, 0.2); } svg { @@ -91,34 +99,34 @@ > a { &:hover, &:focus { - background-color: rgba($color-200, .2); + background-color: rgba($color-200, 0.2); } } } .search { form { - background-color: rgba($color-200, .2); + background-color: rgba($color-200, 0.2); &:hover { - background-color: rgba($color-200, .3); + background-color: rgba($color-200, 0.3); } } .location-badge { color: $color-100; - background-color: rgba($color-200, .1); + background-color: rgba($color-200, 0.1); border-right: 1px solid $color-800; } .search-input::placeholder { - color: rgba($color-200, .8); + color: rgba($color-200, 0.8); } .search-input-wrap { .search-icon, .clear-icon { - fill: rgba($color-200, .8); + fill: rgba($color-200, 0.8); } } @@ -133,7 +141,7 @@ .search-input-wrap { .search-icon { - fill: rgba($color-200, .8); + fill: rgba($color-200, 0.8); } } } @@ -144,7 +152,6 @@ color: $color-900; } - // Sidebar .nav-sidebar li.active { box-shadow: inset 4px 0 0 $color-700; @@ -169,28 +176,94 @@ font-weight: $gl-font-weight-bold; } } -} + // Web IDE + .ide-sidebar-link { + color: $color-200; + background-color: $color-700; + + &:hover, + &:focus { + background-color: $color-500; + } + + &:active { + background: $color-800; + } + } + + .branch-container { + border-left-color: $color-700; + } + + .branch-header-title { + color: $color-700; + } + + .ide-file-list .file.file-active { + color: $color-700; + } +} body { &.ui_indigo { - @include gitlab-theme($indigo-100, $indigo-200, $indigo-500, $indigo-700, $indigo-800, $indigo-900, $white-light); + @include gitlab-theme( + $indigo-100, + $indigo-200, + $indigo-500, + $indigo-700, + $indigo-800, + $indigo-900, + $white-light + ); } &.ui_dark { - @include gitlab-theme($theme-gray-100, $theme-gray-200, $theme-gray-500, $theme-gray-700, $theme-gray-800, $theme-gray-900, $white-light); + @include gitlab-theme( + $theme-gray-100, + $theme-gray-200, + $theme-gray-500, + $theme-gray-700, + $theme-gray-800, + $theme-gray-900, + $white-light + ); } &.ui_blue { - @include gitlab-theme($theme-blue-100, $theme-blue-200, $theme-blue-500, $theme-blue-700, $theme-blue-800, $theme-blue-900, $white-light); + @include gitlab-theme( + $theme-blue-100, + $theme-blue-200, + $theme-blue-500, + $theme-blue-700, + $theme-blue-800, + $theme-blue-900, + $white-light + ); } &.ui_green { - @include gitlab-theme($theme-green-100, $theme-green-200, $theme-green-500, $theme-green-700, $theme-green-800, $theme-green-900, $white-light); + @include gitlab-theme( + $theme-green-100, + $theme-green-200, + $theme-green-500, + $theme-green-700, + $theme-green-800, + $theme-green-900, + $white-light + ); } &.ui_light { - @include gitlab-theme($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-700, $theme-gray-700, $theme-gray-100, $theme-gray-700); + @include gitlab-theme( + $theme-gray-900, + $theme-gray-700, + $theme-gray-800, + $theme-gray-700, + $theme-gray-700, + $theme-gray-100, + $theme-gray-700 + ); .navbar-gitlab { background-color: $theme-gray-100; @@ -270,5 +343,9 @@ body { .sidebar-top-level-items > li.active .badge { color: $theme-gray-900; } + + .ide-sidebar-link { + color: $white-light; + } } } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 48b981dd31f..eb789cc64b0 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -4,9 +4,15 @@ .page-title, .modal-title { + .modal-title-with-label span { + vertical-align: middle; + display: inline-block; + } + .color-label { font-size: $gl-font-size; padding: $gl-vert-padding $label-padding-modal; + vertical-align: middle; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3dd4a613789..798f248dad4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -88,7 +88,6 @@ .right-sidebar { border-left: 1px solid $border-color; - height: calc(100% - #{$header-height}); } .with-performance-bar .right-sidebar.affix { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 7f037582ca0..679f783b1b6 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -813,6 +813,7 @@ } .discussion-notes { + padding: 0 $gl-padding $gl-padding; min-height: 35px; &:first-child { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e21a9f0afc9..2c0ed976301 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -522,10 +522,6 @@ .with-performance-bar .right-sidebar { top: $header-height + $performance-bar-height; - - .issuable-sidebar { - height: calc(100% - #{$performance-bar-height}); - } } .sidebar-move-issue-confirmation-button { diff --git a/app/assets/stylesheets/pages/lint.scss b/app/assets/stylesheets/pages/lint.scss deleted file mode 100644 index 68b6c5ecbd4..00000000000 --- a/app/assets/stylesheets/pages/lint.scss +++ /dev/null @@ -1,21 +0,0 @@ -.ci-body { - .incorrect-syntax { - font-size: 18px; - color: $lint-incorrect-color; - } - - .correct-syntax { - font-size: 18px; - color: $lint-correct-color; - } -} - -.ci-linter { - .ci-editor { - height: 400px; - } - - .ci-template pre { - white-space: pre-wrap; - } -} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 4a528bc2bb1..8720f821ce9 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -173,11 +173,7 @@ } .discussion-form { - background-color: $white-light; -} - -.discussion-form-container { - padding: $gl-padding-top $gl-padding $gl-padding; + padding-top: $gl-padding-top; } .discussion-notes .disabled-comment { @@ -237,12 +233,7 @@ .discussion-body, .diff-file { .discussion-reply-holder { - background-color: $white-light; - padding: 10px 16px; - - &.is-replying { - padding-bottom: $gl-padding; - } + padding-top: $gl-padding; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 81e98f358a8..9d9cbecc958 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -47,7 +47,7 @@ ul.notes { } .timeline-entry-inner { - padding: $gl-padding $gl-btn-padding; + padding: $gl-padding 0; border-bottom: 1px solid $white-normal; } @@ -94,12 +94,6 @@ ul.notes { } } - &.note-discussion { - .timeline-entry-inner { - padding: $gl-padding 10px; - } - } - .editing-spinner { display: none; } @@ -352,6 +346,8 @@ ul.notes { } .discussion-notes { + background-color: $white-light; + &:not(:first-child) { border-top: 1px solid $white-normal; margin-top: 20px; @@ -363,10 +359,6 @@ ul.notes { } } - .notes { - background-color: $white-light; - } - a code { top: 0; margin-right: 0; @@ -647,8 +639,6 @@ ul.notes { border-bottom: 1px solid $white-normal; .timeline-entry-inner { - padding-left: $gl-padding; - padding-right: $gl-padding; border-bottom: 0; } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 42772f13155..ce2f1482456 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -706,8 +706,8 @@ button.mini-pipeline-graph-dropdown-toggle { // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { - width: 195px; - max-width: 195px; + width: 240px; + max-width: 240px; .scrollable-menu { padding: 0; @@ -750,7 +750,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: #{$ci-action-icon-size - 6}; left: -3px; position: relative; - top: -2px; + top: -1px; &.icon-action-stop, &.icon-action-cancel { @@ -931,13 +931,11 @@ button.mini-pipeline-graph-dropdown-toggle { */ &.dropdown-menu { transform: translate(-80%, 0); - min-width: 150px; @media(min-width: $screen-md-min) { transform: translate(-50%, 0); right: auto; left: 50%; - min-width: 240px; } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 584b0579b72..9a770d77685 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1121,3 +1121,25 @@ pre.light-well { padding-top: $gl-padding; padding-bottom: 37px; } + +.project-ci-body { + .incorrect-syntax { + font-size: 18px; + color: $lint-incorrect-color; + } + + .correct-syntax { + font-size: 18px; + color: $lint-correct-color; + } +} + +.project-ci-linter { + .ci-editor { + height: 400px; + } + + .ci-template pre { + white-space: pre-wrap; + } +} diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 7a8fbfc517d..8cc5c8fc877 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -19,8 +19,7 @@ .ide-view { display: flex; height: calc(100vh - #{$header-height}); - margin-top: 40px; - color: $almost-black; + margin-top: 0; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -43,13 +42,18 @@ cursor: pointer; &.file-open { - background: $white-normal; + background: $link-active-background; + } + + &.file-active { + font-weight: $gl-font-weight-bold; } .ide-file-name { flex: 1; white-space: nowrap; text-overflow: ellipsis; + max-width: inherit; svg { vertical-align: middle; @@ -72,7 +76,10 @@ margin-right: -8px; } - &:hover { + &:hover, + &:focus { + background: $link-active-background; + .ide-new-btn { display: block; } @@ -290,6 +297,10 @@ .margin-view-overlays .delete-sign { opacity: 0.4; } + + .cursors-layer { + display: none; + } } } @@ -297,14 +308,34 @@ height: 100%; } -.multi-file-editor-btn-group { - padding: $gl-bar-padding $gl-padding; - border-top: 1px solid $white-dark; +.preview-container { + height: 100%; + overflow: auto; + + .md-previewer { + padding: $gl-padding; + } +} + +.ide-mode-tabs { border-bottom: 1px solid $white-dark; - background: $white-light; + + .nav-links { + border-bottom: 0; + + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; + } + } +} + +.ide-btn-group { + padding: $gl-padding-4 $gl-vert-padding; } .ide-status-bar { + border-top: 1px solid $white-dark; padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; @@ -398,7 +429,7 @@ } .branch-container { - border-left: 4px solid $indigo-700; + border-left: 4px solid; margin-bottom: $gl-bar-padding; } @@ -410,7 +441,6 @@ .branch-header-title { flex: 1; padding: $grid-size $gl-padding; - color: $indigo-700; font-weight: $gl-font-weight-bold; svg { @@ -447,6 +477,8 @@ display: flex; flex-direction: column; flex: 1; + max-height: 100%; + overflow: auto; } .multi-file-commit-empty-state-container { @@ -457,7 +489,7 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - margin-bottom: 12px; + margin-bottom: 0; border-bottom: 1px solid $white-dark; padding: $gl-btn-padding 0; @@ -664,8 +696,14 @@ overflow: hidden; &.nav-only { + padding-top: $header-height; + + .with-performance-bar & { + padding-top: $header-height + $performance-bar-height; + } + .flash-container { - margin-top: $header-height; + margin-top: 0; margin-bottom: 0; } @@ -675,7 +713,7 @@ } .content-wrapper { - margin-top: $header-height; + margin-top: 0; padding-bottom: 0; } @@ -699,11 +737,11 @@ .with-performance-bar .ide.nav-only { .flash-container { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; } .content-wrapper { - margin-top: #{$header-height + $performance-bar-height}; + margin-top: 0; padding-bottom: 0; } @@ -712,14 +750,8 @@ } &.flash-shown { - .content-wrapper { - margin-top: 0; - } - .ide-view { - height: calc( - 100vh - #{$header-height + $performance-bar-height + $flash-height} - ); + height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height}); } } } @@ -763,20 +795,7 @@ .ide-sidebar-link { padding: $gl-padding-8 $gl-padding; - background: $indigo-700; - color: $white-light; - text-decoration: none; display: flex; align-items: center; - - &:focus, - &:hover { - color: $white-light; - text-decoration: underline; - background: $indigo-500; - } - - &:active { - background: $indigo-800; - } + font-weight: $gl-font-weight-bold; } diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig new file mode 100644 index 00000000000..57b995adb64 --- /dev/null +++ b/app/assets/stylesheets/pages/repo.scss.orig @@ -0,0 +1,786 @@ +.project-refs-form, +.project-refs-target-form { + display: inline-block; +} + +.fade-enter, +.fade-leave-to { + opacity: 0; +} + +.commit-message { + @include str-truncated(250px); +} + +.editable-mode { + display: inline-block; +} + +.ide-view { + display: flex; + height: calc(100vh - #{$header-height}); + margin-top: 40px; + color: $almost-black; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + + &.is-collapsed { + .ide-file-list { + max-width: 250px; + } + } + + .file-status-icon { + width: 10px; + height: 10px; + } +} + +.ide-file-list { + flex: 1; + + .file { + cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .ide-file-name { + flex: 1; + white-space: nowrap; + text-overflow: ellipsis; + + svg { + vertical-align: middle; + margin-right: 2px; + } + + .loading-container { + margin-right: 4px; + display: inline-block; + } + } + + .ide-file-changed-icon { + margin-left: auto; + } + + .ide-new-btn { + display: none; + margin-bottom: -4px; + margin-right: -8px; + } + + &:hover { + .ide-new-btn { + display: block; + } + } + + &.folder { + svg { + fill: $gl-text-color-secondary; + } + } + } + + a { + color: $gl-text-color; + } + + th { + position: sticky; + top: 0; + } +} + +.file-name, +.file-col-commit-message { + display: flex; + overflow: visible; + padding: 6px 12px; +} + +.multi-file-loading-container { + margin-top: 10px; + padding: 10px; + + .animation-container { + background: $gray-light; + + div { + background: $gray-light; + } + } +} + +.multi-file-table-col-commit-message { + white-space: nowrap; + width: 50%; +} + +.multi-file-edit-pane { + display: flex; + flex-direction: column; + flex: 1; + border-left: 1px solid $white-dark; + overflow: hidden; +} + +.multi-file-tabs { + display: flex; + background-color: $white-normal; + box-shadow: inset 0 -1px $white-dark; + + > ul { + display: flex; + overflow-x: auto; + } + + li { + position: relative; + } + + .dropdown { + display: flex; + margin-left: auto; + margin-bottom: 1px; + padding: 0 $grid-size; + border-left: 1px solid $white-dark; + background-color: $white-light; + + &.shadow { + box-shadow: 0 0 10px $dropdown-shadow-color; + } + + .btn { + margin-top: auto; + margin-bottom: auto; + } + } +} + +.multi-file-tab { + @include str-truncated(150px); + padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + cursor: pointer; + + svg { + vertical-align: middle; + } + + &.active { + background-color: $white-light; + border-bottom-color: $white-light; + } +} + +.multi-file-tab-close { + position: absolute; + right: 8px; + top: 50%; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: 0; + border-radius: $border-radius-default; + color: $theme-gray-900; + transform: translateY(-50%); + + svg { + position: relative; + top: -1px; + } + + &:hover { + background-color: $theme-gray-200; + } + + &:focus { + background-color: $blue-500; + color: $white-light; + outline: 0; + + svg { + fill: currentColor; + } + } +} + +.multi-file-edit-pane-content { + flex: 1; + height: 0; +} + +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $gray-light; + border-right: 1px solid $white-normal; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + + .cursors-layer { + display: none; + } + } +} + +.multi-file-editor-holder { + height: 100%; +} + +.multi-file-editor-btn-group { + padding: $gl-bar-padding $gl-padding; + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } +} + +// Not great, but this is to deal with our current output +.multi-file-preview-holder { + height: 100%; + overflow: scroll; + + .file-content.code { + display: flex; + + i { + margin-left: -10px; + } + } + + .line-numbers { + min-width: 50px; + } + + .file-content, + .line-numbers, + .blob-content, + .code { + min-height: 100%; + } +} + +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + +.multi-file-commit-panel { + display: flex; + position: relative; + flex-direction: column; + width: 340px; + padding: 0; + background-color: $gray-light; + padding-right: 3px; + + .projects-sidebar { + display: flex; + flex-direction: column; + + .context-header { + width: auto; + margin-right: 0; + } + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + + &.is-collapsed { + width: 60px; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; + } +} + +.multi-file-commit-panel-section { + display: flex; + flex-direction: column; + flex: 1; +} + +.multi-file-commit-empty-state-container { + align-items: center; + justify-content: center; +} + +.multi-file-commit-panel-header { + display: flex; + align-items: center; + margin-bottom: 12px; + border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; + + &.is-collapsed { + border-bottom: 1px solid $white-dark; + + svg { + margin-left: auto; + margin-right: auto; + } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } + } +} + +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: 0 $gl-btn-padding; + + svg { + margin-right: $gl-btn-padding; + } +} + +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + +.multi-file-commit-list { + flex: 1; + overflow: auto; + padding: $gl-padding 0; + min-height: 60px; +} + +.multi-file-commit-list-item { + display: flex; + padding: 0; + align-items: center; + + .multi-file-discard-btn { + display: none; + margin-left: auto; + color: $gl-link-color; + padding: 0 2px; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + &:hover { + background: $white-normal; + + .multi-file-discard-btn { + display: block; + } + } +} + +.multi-file-addition { + fill: $green-500; +} + +.multi-file-modified { + fill: $orange-500; +} + +.multi-file-commit-list-collapsed { + display: flex; + flex-direction: column; + + > svg { + margin-left: auto; + margin-right: auto; + } + + .file-status-icon { + width: 10px; + height: 10px; + margin-left: 3px; + } +} + +.multi-file-commit-list-path { + padding: $grid-size / 2; + padding-left: $gl-padding; + background: none; + border: 0; + text-align: left; + width: 100%; + min-width: 0; + + svg { + min-width: 16px; + vertical-align: middle; + display: inline-block; + } + + &:hover, + &:focus { + outline: 0; + } +} + +.multi-file-commit-list-file-path { + @include str-truncated(100%); + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } +} + +.multi-file-commit-form { + padding: $gl-padding; + border-top: 1px solid $white-dark; + + .btn { + font-size: $gl-font-size; + } +} + +.multi-file-commit-message.form-control { + height: 160px; + resize: none; +} + +.dirty-diff { + // !important need to override monaco inline style + width: 4px !important; + left: 0 !important; + + &-modified { + background-color: $blue-500; + } + + &-added { + background-color: $green-600; + } + + &-removed { + height: 0 !important; + width: 0 !important; + bottom: -2px; + border-style: solid; + border-width: 5px; + border-color: transparent transparent transparent $red-500; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100px; + height: 1px; + background-color: rgba($red-500, 0.5); + } + } +} + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide { + overflow: hidden; + + &.nav-only { + .flash-container { + margin-top: $header-height; + margin-bottom: 0; + } + + .alert-wrapper .flash-container .flash-alert:last-child, + .alert-wrapper .flash-container .flash-notice:last-child { + margin-bottom: 0; + } + + .content-wrapper { + margin-top: $header-height; + padding-bottom: 0; + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $flash-height}); + } + } + + .projects-sidebar { + .multi-file-commit-panel-inner-scroll { + flex: 1; + } + } + } +} + +.with-performance-bar .ide.nav-only { + .flash-container { + margin-top: #{$header-height + $performance-bar-height}; + } + + .content-wrapper { + margin-top: #{$header-height + $performance-bar-height}; + padding-bottom: 0; + } + + .ide-view { + height: calc(100vh - #{$header-height + $performance-bar-height}); + } + + &.flash-shown { + .content-wrapper { + margin-top: 0; + } + + .ide-view { + height: calc( + 100vh - #{$header-height + $performance-bar-height + $flash-height} + ); + } + } +} + +.dragHandle { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background-color: $white-dark; + + &.dragright { + right: 0; + } + + &.dragleft { + left: 0; + } +} + +.ide-commit-radios { + label { + font-weight: normal; + } + + .help-block { + margin-top: 0; + line-height: 0; + } +} + +.ide-commit-new-branch { + margin-left: 25px; +} + +.ide-external-links { + p { + margin: 0; + } +} + +.ide-sidebar-link { + padding: $gl-padding-8 $gl-padding; + background: $indigo-700; + color: $white-light; + text-decoration: none; + display: flex; + align-items: center; + + &:focus, + &:hover { + color: $white-light; + text-decoration: underline; + background: $indigo-500; + } + + &:active { + background: $indigo-800; + } +} diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index dd0b38970bd..ea302f17d16 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -50,9 +50,19 @@ class Admin::AppearancesController < Admin::ApplicationController # Only allow a trusted parameter "white list" through. def appearance_params - params.require(:appearance).permit( - :title, :description, :logo, :logo_cache, :header_logo, :header_logo_cache, - :new_project_guidelines, :updated_by - ) + params.require(:appearance).permit(allowed_appearance_params) + end + + def allowed_appearance_params + %i[ + title + description + logo + logo_cache + header_logo + header_logo_cache + new_project_guidelines + updated_by + ] end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index cc38608eda5..001f6520093 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController def index @groups = Group.with_statistics.with_route - @groups = @groups.sort(@sort = params[:sort]) + @groups = @groups.sort_by_attribute(@sort = params[:sort]) @groups = @groups.search(params[:name]) if params[:name].present? @groups = @groups.page(params[:page]) end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 156a8e2c515..bfeb5a2d097 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -4,7 +4,7 @@ class Admin::UsersController < Admin::ApplicationController def index @users = User.order_name_asc.filter(params[:filter]) @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? - @users = @users.sort(@sort = params[:sort]) + @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f83bd10e93..24651dd392c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -229,10 +229,6 @@ class ApplicationController < ActionController::Base @event_filter ||= EventFilter.new(filters) end - def gitlab_ldap_access(&block) - Gitlab::Auth::LDAP::Access.open { |access| yield(access) } - end - # JSON for infinite scroll via Pager object def pager_json(partial, count, locals = {}) html = render_to_string( diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb index e9bd1689a1e..738a6a5173e 100644 --- a/app/controllers/ci/lints_controller.rb +++ b/app/controllers/ci/lints_controller.rb @@ -4,20 +4,5 @@ module Ci def show end - - def create - @content = params[:content] - @error = Gitlab::Ci::YamlProcessor.validation_message(@content) - @status = @error.blank? - - if @error.blank? - @config_processor = Gitlab::Ci::YamlProcessor.new(@content) - @stages = @config_processor.stages - @builds = @config_processor.builds - @jobs = @config_processor.jobs - end - - render :show - end end end diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index fafb10090ca..56770a17406 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -14,7 +14,7 @@ module GroupTree end @groups = @groups.with_selects_for_list(archived: params[:archived]) - .sort(@sort = params[:sort]) + .sort_by_attribute(@sort = params[:sort]) .page(params[:page]) respond_to do |format| diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index a21e658fda1..0379f76fc3d 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -88,11 +88,15 @@ module IssuableActions discussions = Discussion.build_collection(notes, issuable) - render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self) + render json: discussion_serializer.represent(discussions, context: self) end private + def discussion_serializer + DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity) + end + def recaptcha_check_if_spammable(should_redirect = true, &block) return yield unless issuable.is_a? Spammable diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 03ed5b5310b..839cac3687c 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -212,7 +212,7 @@ module NotesActions end def note_serializer - NoteSerializer.new(project: project, noteable: noteable, current_user: current_user) + ProjectNoteSerializer.new(project: project, noteable: noteable, current_user: current_user) end def note_project diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb new file mode 100644 index 00000000000..55011c89886 --- /dev/null +++ b/app/controllers/concerns/send_file_upload.rb @@ -0,0 +1,17 @@ +module SendFileUpload + def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, disposition: 'attachment') + if attachment + redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}" } + send_params.merge!(filename: attachment, disposition: disposition) + end + + if file_upload.file_storage? + send_file file_upload.path, send_params + elsif file_upload.class.proxy_download_enabled? + headers.store(*Gitlab::Workhorse.send_url(file_upload.url(**redirect_params))) + head :ok + else + redirect_to file_upload.url(**redirect_params) + end + end +end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 3dbfabcae8a..b9b9b6e4e88 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,5 +1,6 @@ module UploadsActions include Gitlab::Utils::StrongMemoize + include SendFileUpload UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo).freeze @@ -26,14 +27,11 @@ module UploadsActions def show return render_404 unless uploader&.exists? - if uploader.file_storage? - disposition = uploader.image_or_video? ? 'inline' : 'attachment' - expires_in 0.seconds, must_revalidate: true, private: true + expires_in 0.seconds, must_revalidate: true, private: true - send_file uploader.file.path, disposition: disposition - else - redirect_to uploader.url - end + disposition = uploader.image_or_video? ? 'inline' : 'attachment' + + send_upload(uploader, attachment: uploader.filename, disposition: disposition) end private @@ -62,19 +60,27 @@ module UploadsActions end def build_uploader_from_upload - return nil unless params[:secret] && params[:filename] + return unless uploader = build_uploader - upload_path = uploader_class.upload_path(params[:secret], params[:filename]) - upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_path) + upload_paths = uploader.upload_paths(params[:filename]) + upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_paths) upload&.build_uploader end def build_uploader_from_params + return unless uploader = build_uploader + + uploader.retrieve_from_store!(params[:filename]) + uploader + end + + def build_uploader + return unless params[:secret] && params[:filename] + uploader = uploader_class.new(model, secret: params[:secret]) - return nil unless uploader.model_valid? + return unless uploader.model_valid? - uploader.retrieve_from_store!(params[:filename]) uploader end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index f210434b2d7..134b0dfc0db 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -17,7 +17,7 @@ class Groups::GroupMembersController < Groups::ApplicationController @members = GroupMembersFinder.new(@group).execute @members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.search(params[:search]) if params[:search].present? - @members = @members.sort(@sort) + @members = @members.sort_by_attribute(@sort) @members = @members.page(params[:page]).per(50) @members = present_members(@members.includes(:user)) diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index cb8771bc97e..6142e75b4c1 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -39,7 +39,7 @@ module Groups end def variable_params_attributes - %i[id key value protected _destroy] + %i[id key secret_value protected _destroy] end def authorize_admin_build! diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index dbf61a17724..3d27ae18b17 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -51,7 +51,7 @@ class ProfilesController < Profiles::ApplicationController end def update_username - result = Users::UpdateService.new(current_user, user: @user, username: user_params[:username]).execute + result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute options = if result[:status] == :success { notice: "Username successfully changed" } @@ -72,6 +72,10 @@ class ProfilesController < Profiles::ApplicationController return render_404 unless @user.can_change_username? end + def username_param + @username_param ||= user_params.require(:username) + end + def user_params @user_params ||= params.require(:user).permit( :avatar, diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 0837451cc49..abc283d7aa9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,6 +1,7 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath include RendersBlob + include SendFileUpload layout 'project' before_action :authorize_read_build! @@ -10,11 +11,7 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :entry, only: [:file] def download - if artifacts_file.file_storage? - send_file artifacts_file.path, disposition: 'attachment' - else - redirect_to artifacts_file.url - end + send_upload(artifacts_file, attachment: artifacts_file.filename) end def browse @@ -45,8 +42,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def raw - path = Gitlab::Ci::Build::Artifacts::Path - .new(params[:path]) + path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path]) send_artifacts_entry(build, path) end @@ -75,7 +71,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def validate_artifacts! - render_404 unless build && build.artifacts? + render_404 unless build&.artifacts? end def build diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 965cece600e..b7b36f770f5 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -21,17 +21,17 @@ class Projects::BranchesController < Projects::ApplicationController fetch_branches_by_mode @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) - @merged_branch_names = - repository.merged_branch_names(@branches.map(&:name)) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37429 + @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) + + # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992 Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max end - - render end + + render end format.json do branches = BranchesFinder.new(@repository, params).execute diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb new file mode 100644 index 00000000000..a2185572a20 --- /dev/null +++ b/app/controllers/projects/ci/lints_controller.rb @@ -0,0 +1,27 @@ +class Projects::Ci::LintsController < Projects::ApplicationController + before_action :authorize_create_pipeline! + + def show + end + + def create + @content = params[:content] + @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options) + @status = @error.blank? + + if @error.blank? + @config_processor = Gitlab::Ci::YamlProcessor.new(@content, yaml_processor_options) + @stages = @config_processor.stages + @builds = @config_processor.builds + @jobs = @config_processor.jobs + end + + render :show + end + + private + + def yaml_processor_options + { project: @project, sha: project.repository.commit.sha } + end +end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index cba9a53dc4b..8e86af43fee 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController before_action :check_merge_requests_available! before_action :merge_request - before_action :discussion - before_action :authorize_resolve_discussion! + before_action :discussion, only: [:resolve, :unresolve] + before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve] def resolve Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) @@ -43,7 +43,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_json_with_discussions_serializer render json: - DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user) + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) .represent(discussion, context: self) end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 8b54ba3ad7c..85e972d9731 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -1,4 +1,6 @@ class Projects::JobsController < Projects::ApplicationController + include SendFileUpload + before_action :build, except: [:index, :cancel_all] before_action :authorize_read_build!, @@ -117,11 +119,17 @@ class Projects::JobsController < Projects::ApplicationController end def raw - build.trace.read do |stream| - if stream.file? - send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 + if trace_artifact_file + send_upload(trace_artifact_file, + send_params: raw_send_params, + redirect_params: raw_redirect_params) + else + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end end end end @@ -136,9 +144,21 @@ class Projects::JobsController < Projects::ApplicationController return access_denied! unless can?(current_user, :erase_build, build) end + def raw_send_params + { type: 'text/plain; charset=utf-8', disposition: 'inline' } + end + + def raw_redirect_params + { query: { 'response-content-type' => 'text/plain; charset=utf-8', 'response-content-disposition' => 'inline' } } + end + + def trace_artifact_file + @trace_artifact_file ||= build.job_artifacts_trace&.file + end + def build @build ||= project.builds.find(params[:id]) - .present(current_user: current_user) + .present(current_user: current_user) end def build_path(build) diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 99790b8e7e8..91016f6494e 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -112,7 +112,7 @@ class Projects::LabelsController < Projects::ApplicationController begin return render_404 unless promote_service.execute(@label) - flash[:notice] = "#{@label.title} promoted to group label." + flash[:notice] = "#{@label.title} promoted to <a href=\"#{group_labels_path(@project.group)}\">group label</a>.".html_safe respond_to do |format| format.html do redirect_to(project_labels_path(@project), status: 303) @@ -150,7 +150,8 @@ class Projects::LabelsController < Projects::ApplicationController end def find_labels - @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute + @available_labels ||= + LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups]).execute end def authorize_admin_labels! diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index c77f10ef1dd..ee4ed674110 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -41,7 +41,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController def existing_oids @existing_oids ||= begin - storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) + project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) end end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index 941638db427..2515e4b9a17 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -1,6 +1,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController include LfsRequest include WorkhorseRequest + include SendFileUpload skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize] @@ -11,25 +12,28 @@ class Projects::LfsStorageController < Projects::GitHttpClientController return end - send_file lfs_object.file.path, content_type: "application/octet-stream" + send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" }) end def upload_authorize set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.lfs_upload_ok(oid, size) + + authorized = LfsObjectUploader.workhorse_authorize + authorized.merge!(LfsOid: oid, LfsSize: size) + + render json: authorized end def upload_finalize - unless tmp_filename - render_lfs_forbidden - return - end - - if store_file(oid, size, tmp_filename) + if store_file!(oid, size) head 200 else render plain: 'Unprocessable entity', status: 422 end + rescue ActiveRecord::RecordInvalid + render_400 + rescue ObjectStorage::RemoteStoreError + render_lfs_forbidden end private @@ -50,38 +54,28 @@ class Projects::LfsStorageController < Projects::GitHttpClientController params[:size].to_i end - def tmp_filename - name = request.headers['X-Gitlab-Lfs-Tmp'] - return if name.include?('/') - return unless oid.present? && name.start_with?(oid) - - name - end + def store_file!(oid, size) + object = LfsObject.find_by(oid: oid, size: size) + unless object&.file&.exists? + object = create_file!(oid, size) + end - def store_file(oid, size, tmp_file) - # Define tmp_file_path early because we use it in "ensure" - tmp_file_path = File.join(LfsObjectUploader.workhorse_upload_path, tmp_file) + return unless object - object = LfsObject.find_or_create_by(oid: oid, size: size) - file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path) - file_exists && link_to_project(object) - ensure - FileUtils.rm_f(tmp_file_path) + link_to_project!(object) end - def move_tmp_file_to_storage(object, path) - File.open(path) do |f| - object.file = f + def create_file!(oid, size) + LfsObject.new(oid: oid, size: size).tap do |object| + object.file.store_workhorse_file!(params, :file) + object.save! end - - object.file.store! - object.save end - def link_to_project(object) + def link_to_project!(object) if object && !object.projects.exists?(storage_project.id) object.projects << storage_project - object.save + object.save! end end end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index ff93147d00f..c5a044541f1 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -14,7 +14,7 @@ class Projects::MilestonesController < Projects::ApplicationController def index @sort = params[:sort] || 'due_date_asc' - @milestones = milestones.sort(@sort) + @milestones = milestones.sort_by_attribute(@sort) respond_to do |format| format.html do @@ -42,6 +42,10 @@ class Projects::MilestonesController < Projects::ApplicationController def show @project_namespace = @project.namespace.becomes(Namespace) + + respond_to do |format| + format.html + end end def create @@ -70,9 +74,9 @@ class Projects::MilestonesController < Projects::ApplicationController end def promote - Milestones::PromoteService.new(project, current_user).execute(milestone) + promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "#{milestone.title} promoted to group milestone" + flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\">group milestone</a>.".html_safe respond_to do |format| format.html do redirect_to project_milestones_path(project) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index b478e7b5e05..fa258f3d9af 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -92,7 +92,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def schedule_params params.require(:schedule) .permit(:description, :cron, :cron_timezone, :ref, :active, - variables_attributes: [:id, :key, :value, :_destroy] ) + variables_attributes: [:id, :key, :secret_value, :_destroy] ) end def authorize_play_pipeline_schedule! diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 557671ab186..73c613b26f3 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -4,41 +4,4 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show redirect_to project_settings_ci_cd_path(@project, params: params) end - - def update - Projects::UpdateService.new(project, current_user, update_params).tap do |service| - if service.execute - flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." - - run_autodevops_pipeline(service) - - redirect_to project_settings_ci_cd_path(@project) - else - render 'show' - end - end - end - - private - - def run_autodevops_pipeline(service) - return unless service.run_auto_devops_pipeline? - - if @project.empty_repo? - flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." - return - end - - CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) - flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe - end - - def update_params - params.require(:project).permit( - :runners_token, :builds_enabled, :build_allow_git_fetch, - :build_timeout_in_minutes, :build_coverage_regex, :public_builds, - :auto_cancel_pending_pipelines, :ci_config_path, - auto_devops_attributes: [:id, :domain, :enabled] - ) - end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e9b4679f94c..cfa5e72af64 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -21,7 +21,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) end - @project_members = present_members(@project_members.sort(@sort).page(params[:page])) + @project_members = present_members(@project_members.sort_by_attribute(@sort).page(params[:page])) @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user)) @project_member = @project.project_members.new end diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index d1719f12072..64954ac9a42 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -5,12 +5,8 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController @project.repository.branches end - def create_service_class - ::ProtectedBranches::CreateService - end - - def update_service_class - ::ProtectedBranches::UpdateService + def service_namespace + ::ProtectedBranches end def load_protected_ref diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index b51bdf7aa78..9e757a8d25f 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -37,7 +37,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController end def destroy - @protected_ref.destroy + destroy_service_class.new(@project, current_user).execute(@protected_ref) respond_to do |format| format.html { redirect_to_repository_settings(@project) } @@ -47,6 +47,18 @@ class Projects::ProtectedRefsController < Projects::ApplicationController protected + def create_service_class + service_namespace::CreateService + end + + def update_service_class + service_namespace::UpdateService + end + + def destroy_service_class + service_namespace::DestroyService + end + def access_level_attributes %i(access_level id) end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb index a5dbd7e46ae..198c938ff35 100644 --- a/app/controllers/projects/protected_tags_controller.rb +++ b/app/controllers/projects/protected_tags_controller.rb @@ -5,12 +5,8 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController @project.repository.tags end - def create_service_class - ::ProtectedTags::CreateService - end - - def update_service_class - ::ProtectedTags::UpdateService + def service_namespace + ::ProtectedTags end def load_protected_ref diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index a02cc477e08..9bc774b7636 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -2,6 +2,7 @@ class Projects::RawController < Projects::ApplicationController include ExtractsPath include BlobHelper + include SendFileUpload before_action :require_non_empty_project before_action :assign_ref_vars @@ -31,7 +32,7 @@ class Projects::RawController < Projects::ApplicationController lfs_object = find_lfs_object if lfs_object && lfs_object.project_allowed_access?(@project) - send_file lfs_object.file.path, filename: @blob.name, disposition: 'attachment' + send_upload(lfs_object.file, attachment: @blob.name) else render_404 end diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 2376f469213..48a09e1ddb8 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -25,7 +25,7 @@ class Projects::RefsController < Projects::ApplicationController when "graphs_commits" commits_project_graph_path(@project, @id) when "badges" - project_pipelines_settings_path(@project, ref: @id) + project_settings_ci_cd_path(@project, ref: @id) else project_commits_path(@project, @id) end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f14cb5f6a9f..a5ea9ff7ed7 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -46,6 +46,8 @@ class Projects::ServicesController < Projects::ApplicationController else { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') } end + rescue Gitlab::HTTP::BlockedUrlError => e + { error: true, message: 'Test failed.', service_response: e.message } end def success_message diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 259809f3429..d80ef8113aa 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -2,13 +2,24 @@ module Projects module Settings class CiCdController < Projects::ApplicationController before_action :authorize_admin_pipeline! + before_action :define_variables def show - define_runners_variables - define_secret_variables - define_triggers_variables - define_badges_variables - define_auto_devops_variables + end + + def update + Projects::UpdateService.new(project, current_user, update_params).tap do |service| + result = service.execute + if result[:status] == :success + flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." + + run_autodevops_pipeline(service) + + redirect_to project_settings_ci_cd_path(@project) + else + render 'show' + end + end end def reset_cache @@ -25,16 +36,45 @@ module Projects private + def update_params + params.require(:project).permit( + :runners_token, :builds_enabled, :build_allow_git_fetch, + :build_timeout_human_readable, :build_coverage_regex, :public_builds, + :auto_cancel_pending_pipelines, :ci_config_path, + auto_devops_attributes: [:id, :domain, :enabled] + ) + end + + def run_autodevops_pipeline(service) + return unless service.run_auto_devops_pipeline? + + if @project.empty_repo? + flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch." + return + end + + CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe + end + + def define_variables + define_runners_variables + define_secret_variables + define_triggers_variables + define_badges_variables + define_auto_devops_variables + end + def define_runners_variables @project_runners = @project.runners.ordered @assignable_runners = current_user.ci_authorized_runners .assignable_for(project).ordered.page(params[:page]).per(20) - @shared_runners = Ci::Runner.shared.active + @shared_runners = ::Ci::Runner.shared.active @shared_runners_count = @shared_runners.count(:all) end def define_secret_variables - @variable = Ci::Variable.new(project: project) + @variable = ::Ci::Variable.new(project: project) .present(current_user: current_user) @variables = project.variables.order_key_asc .map { |variable| variable.present(current_user: current_user) } @@ -42,7 +82,7 @@ module Projects def define_triggers_variables @triggers = @project.triggers - @trigger = Ci::Trigger.new + @trigger = ::Ci::Trigger.new end def define_badges_variables diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index d06d18c498b..dd9e4a2af3e 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -16,6 +16,10 @@ module Projects @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new @protected_tag = @project.protected_tags.new + + @protected_branches_count = @protected_branches.reduce(0) { |sum, branch| sum + branch.matching(@project.repository.branches).size } + @protected_tags_count = @protected_tags.reduce(0) { |sum, tag| sum + tag.matching(@project.repository.tags).size } + load_gon_index end diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 7eb509e2e64..517d0b026c2 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -36,6 +36,6 @@ class Projects::VariablesController < Projects::ApplicationController end def variable_params_attributes - %i[id key value protected _destroy] + %i[id key secret_value protected _destroy] end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ee197c75764..37f14230196 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -324,7 +324,7 @@ class ProjectsController < Projects::ApplicationController :avatar, :build_allow_git_fetch, :build_coverage_regex, - :build_timeout_in_minutes, + :build_timeout_human_readable, :resolve_outdated_diff_discussions, :container_registry_enabled, :default_branch, diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 8acefd58e77..651b82f04f4 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -42,6 +42,10 @@ class RootController < Dashboard::ProjectsController redirect_to(dashboard_groups_path) when 'todos' redirect_to(dashboard_todos_path) + when 'issues' + redirect_to(issues_dashboard_path(assignee_id: current_user.id)) + when 'merge_requests' + redirect_to(merge_requests_dashboard_path(assignee_id: current_user.id)) end end diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index 2c8f21c2400..53b77f5fed9 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -62,6 +62,6 @@ class Admin::ProjectsFinder def sort(items) sort = params.fetch(:sort) { 'latest_activity_desc' } - items.sort(sort) + items.sort_by_attribute(sort) end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b2d4f9938ff..61c72aa22a8 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -337,7 +337,7 @@ class IssuableFinder def sort(items) # Ensure we always have an explicit sort order (instead of inheriting # multiple orders when combining ActiveRecord::Relation objects). - params[:sort] ? items.sort(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc) + params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc) end def by_assignee(items) diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index 780c0fdb03e..afd1f824b32 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -28,9 +28,10 @@ class LabelsFinder < UnionFinder if project if project.group.present? labels_table = Label.arel_table + group_ids = group_ids_for(project.group) label_ids << Label.where( - labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].eq(project.group.id)).or( + labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].in(group_ids)).or( labels_table[:type].eq('ProjectLabel').and(labels_table[:project_id].eq(project.id)) ) ) @@ -38,11 +39,14 @@ class LabelsFinder < UnionFinder label_ids << project.labels end end - elsif only_group_labels? - label_ids << Label.where(group_id: group_ids) else + if group? + group = Group.find(params[:group_id]) + label_ids << Label.where(group_id: group_ids_for(group)) + end + label_ids << Label.where(group_id: projects.group_ids) - label_ids << Label.where(project_id: projects.select(:id)) + label_ids << Label.where(project_id: projects.select(:id)) unless only_group_labels? end label_ids @@ -59,22 +63,33 @@ class LabelsFinder < UnionFinder items.where(title: title) end - def group_ids + # Gets redacted array of group ids + # which can include the ancestors and descendants of the requested group. + def group_ids_for(group) strong_memoize(:group_ids) do - groups_user_can_read_labels(groups_to_include).map(&:id) + groups = groups_to_include(group) + + groups_user_can_read_labels(groups).map(&:id) end end - def groups_to_include - group = Group.find(params[:group_id]) + def groups_to_include(group) groups = [group] - groups += group.ancestors if params[:include_ancestor_groups].present? - groups += group.descendants if params[:include_descendant_groups].present? + groups += group.ancestors if include_ancestor_groups? + groups += group.descendants if include_descendant_groups? groups end + def include_ancestor_groups? + params[:include_ancestor_groups] + end + + def include_descendant_groups? + params[:include_descendant_groups] + end + def group? params[:group_id].present? end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 005612ededc..c7d6bc6cfdc 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -124,7 +124,7 @@ class ProjectsFinder < UnionFinder end def sort(items) - params[:sort].present? ? items.sort(params[:sort]) : items.order_id_desc + params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc end def by_archived(projects) diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 150f4c7688b..09e2c586f2a 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -119,7 +119,7 @@ class TodosFinder end def sort(items) - params[:sort] ? items.sort(params[:sort]) : items.order_id_desc + params[:sort] ? items.sort_by_attribute(params[:sort]) : items.order_id_desc end def by_action(items) diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index c037de33c22..f48db024e3f 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,27 +1,27 @@ module AppearancesHelper def brand_title - brand_item&.title.presence || 'GitLab Community Edition' + current_appearance&.title.presence || 'GitLab Community Edition' end def brand_image - image_tag(brand_item.logo) if brand_item&.logo? + image_tag(current_appearance.logo) if current_appearance&.logo? end def brand_text - markdown_field(brand_item, :description) + markdown_field(current_appearance, :description) end def brand_new_project_guidelines - markdown_field(brand_item, :new_project_guidelines) + markdown_field(current_appearance, :new_project_guidelines) end - def brand_item + def current_appearance @appearance ||= Appearance.current end def brand_header_logo - if brand_item&.header_logo? - image_tag brand_item.header_logo + if current_appearance&.header_logo? + image_tag current_appearance.header_logo else render 'shared/logo.svg' end @@ -29,7 +29,7 @@ module AppearancesHelper # Skip the 'GitLab' type logo when custom brand logo is set def brand_header_logo_type - unless brand_item&.header_logo? + unless current_appearance&.header_logo? render 'shared/logo_type.svg' end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 701be97ee96..86ec500ceb3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -285,6 +285,10 @@ module ApplicationHelper class_names end + # EE feature: System header and footer, unavailable in CE + def system_message_class + end + # Returns active css class when condition returns true # otherwise returns nil. # diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 275e892b2e6..af878bcf9a0 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -53,10 +53,12 @@ module BoardsHelper end def board_list_data + include_descendant_groups = @group&.present? + { toggle: "dropdown", - list_labels_path: labels_filter_path(true), - labels: labels_filter_path(true), + list_labels_path: labels_filter_path(true, include_ancestor_groups: true), + labels: labels_filter_path(true, include_descendant_groups: include_descendant_groups), labels_endpoint: @labels_endpoint, namespace_path: @namespace_path, project_path: @project&.path, diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 4ddc1dbed49..c86a26ac30f 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -54,9 +54,9 @@ module EmailsHelper end def header_logo - if brand_item && brand_item.header_logo? + if current_appearance&.header_logo? image_tag( - brand_item.header_logo, + current_appearance.header_logo, style: 'height: 50px' ) else diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 87ff607dc3f..c4a6a1e4bb3 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -129,13 +129,17 @@ module LabelsHelper end end - def labels_filter_path(only_group_labels = false) + def labels_filter_path(only_group_labels = false, include_ancestor_groups: true, include_descendant_groups: false) project = @target_project || @project + options = {} + options[:include_ancestor_groups] = include_ancestor_groups if include_ancestor_groups + options[:include_descendant_groups] = include_descendant_groups if include_descendant_groups + if project - project_labels_path(project, :json) + project_labels_path(project, :json, options) elsif @group - options = { only_group_labels: only_group_labels } if only_group_labels + options[:only_group_labels] = only_group_labels if only_group_labels group_labels_path(@group, :json, options) else dashboard_labels_path(:json) diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 40ca666f1bf..9be93fa69ae 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -31,7 +31,7 @@ module NamespacesHelper def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) - group_icon(namespace) + group_icon_url(namespace) else avatar_icon_for_user(namespace.owner, size) end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 20aed60cb7a..27ed48fdbc7 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -151,16 +151,17 @@ module NotesHelper } end - def notes_data(issuable) - discussions_path = - if issuable.is_a?(Issue) - discussions_project_issue_path(@project, issuable, format: :json) - else - discussions_project_merge_request_path(@project, issuable, format: :json) - end + def discussions_path(issuable) + if issuable.is_a?(Issue) + discussions_project_issue_path(@project, issuable, format: :json) + else + discussions_project_merge_request_path(@project, issuable, format: :json) + end + end + def notes_data(issuable) { - discussionsPath: discussions_path, + discussionsPath: discussions_path(issuable), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), @@ -170,7 +171,6 @@ module NotesHelper notesPath: notes_url, totalNotes: issuable.discussions.length, lastFetchedAt: Time.now.to_i - }.to_json end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 18b9bf214a3..a8397b03d63 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -39,7 +39,10 @@ module PageLayoutHelper end def favicon - Rails.env.development? ? 'favicon-blue.ico' : 'favicon.ico' + return 'favicon-yellow.ico' if Gitlab::Utils.to_boolean(ENV['CANARY']) + return 'favicon-blue.ico' if Rails.env.development? + + 'favicon.ico' end def page_image diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 373dfd457f7..fb523cb865b 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -9,12 +9,14 @@ module PreferencesHelper # Maps `dashboard` values to more user-friendly option text DASHBOARD_CHOICES = { - projects: 'Your Projects (default)', - stars: 'Starred Projects', - project_activity: "Your Projects' Activity", - starred_project_activity: "Starred Projects' Activity", - groups: "Your Groups", - todos: "Your Todos" + projects: _("Your Projects (default)"), + stars: _("Starred Projects"), + project_activity: _("Your Projects' Activity"), + starred_project_activity: _("Starred Projects' Activity"), + groups: _("Your Groups"), + todos: _("Your Todos"), + issues: _("Assigned Issues"), + merge_requests: _("Assigned Merge Requests") }.with_indifferent_access.freeze # Returns an Array usable by a select field for more user-friendly option text diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index f435c80c656..f872990122e 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -1,4 +1,29 @@ module ServicesHelper + def service_event_description(event) + case event + when "push", "push_events" + "Event will be triggered by a push to the repository" + when "tag_push", "tag_push_events" + "Event will be triggered when a new tag is pushed to the repository" + when "note", "note_events" + "Event will be triggered when someone adds a comment" + when "confidential_note", "confidential_note_events" + "Event will be triggered when someone adds a comment on a confidential issue" + when "issue", "issue_events" + "Event will be triggered when an issue is created/updated/closed" + when "confidential_issue", "confidential_issues_events" + "Event will be triggered when a confidential issue is created/updated/closed" + when "merge_request", "merge_request_events" + "Event will be triggered when a merge request is created/updated/merged" + when "pipeline", "pipeline_events" + "Event will be triggered when a pipeline status changes" + when "wiki_page", "wiki_page_events" + "Event will be triggered when a wiki page is created/updated" + when "commit", "commit_events" + "Event will be triggered when a commit is created/updated" + end + end + def service_event_field_name(event) event = event.pluralize if %w[merge_request issue confidential_issue].include?(event) "#{event}_events" diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index b64be89c181..5e7c20ef51e 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -123,7 +123,7 @@ module TreeHelper # returns the relative path of the first subdir that doesn't have only one directory descendant def flatten_tree(root_path, tree) - return tree.flat_path.sub(%r{\A#{root_path}/}, '') if tree.flat_path.present? + return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present? subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path) if subtree.count == 1 && subtree.first.dir? diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 5fe09cea83f..b3f2aeb08ca 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -11,6 +11,15 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end + def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], existing_commits: []) + setup_merge_request_mail(merge_request_id, recipient_id) + @new_commits = new_commits + @existing_commits = existing_commits + @updated_by_user = User.find(updated_by_user_id) + + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + end + def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/models/appearance.rb b/app/models/appearance.rb index dcd14c08f3c..2a6406d63c7 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -1,5 +1,7 @@ class Appearance < ActiveRecord::Base include CacheMarkdownField + include AfterCommitQueue + include ObjectStorage::BackgroundMove cache_markdown_field :description cache_markdown_field :new_project_guidelines diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb index ec56cc53aea..760f01f225b 100644 --- a/app/models/ci/artifact_blob.rb +++ b/app/models/ci/artifact_blob.rb @@ -36,16 +36,15 @@ module Ci def external_url(project, job) return unless external_link?(job) - full_path_parts = project.full_path_components - top_level_group = full_path_parts.shift + url_project_path = project.full_path.partition('/').last artifact_path = [ - '-', *full_path_parts, '-', + '-', url_project_path, '-', 'jobs', job.id, 'artifacts', path ].join('/') - "#{pages_config.protocol}://#{top_level_group}.#{pages_config.host}/#{artifact_path}" + "#{project.pages_group_url}/#{artifact_path}" end def external_link?(job) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1e066b69c6e..4aa65bf4273 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,8 +3,10 @@ module Ci prepend ArtifactMigratable include TokenAuthenticatable include AfterCommitQueue + include ObjectStorage::BackgroundMove include Presentable include Importable + include Gitlab::Utils::StrongMemoize MissingDependenciesError = Class.new(StandardError) @@ -23,12 +25,18 @@ module Ci has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id - # The "environment" field for builds is a String, and is the unexpanded name + has_one :metadata, class_name: 'Ci::BuildMetadata' + delegate :timeout, to: :metadata, prefix: true, allow_nil: true + + ## + # The "environment" field for builds is a String, and is the unexpanded name! + # def persisted_environment - @persisted_environment ||= Environment.find_by( - name: expanded_environment_name, - project: project - ) + return unless has_environment? + + strong_memoize(:persisted_environment) do + Environment.find_by(name: expanded_environment_name, project: project) + end end serialize :options # rubocop:disable Cop/ActiveRecordSerialize @@ -45,6 +53,7 @@ module Ci where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end + scope :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -81,6 +90,7 @@ module Ci before_save :ensure_token before_destroy { unscoped_project } + before_create :ensure_metadata after_create unless: :importing? do |build| run_after_commit { BuildHooksWorker.perform_async(build.id) } end @@ -151,6 +161,14 @@ module Ci before_transition any => [:running] do |build| build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies') end + + before_transition pending: :running do |build| + build.ensure_metadata.update_timeout_state + end + end + + def ensure_metadata + metadata || build_metadata(project: project) end def detailed_status(current_user) @@ -198,7 +216,11 @@ module Ci end def expanded_environment_name - ExpandVariables.expand(environment, simple_variables) if environment + return unless has_environment? + + strong_memoize(:expanded_environment_name) do + ExpandVariables.expand(environment, simple_variables) + end end def has_environment? @@ -229,10 +251,6 @@ module Ci latest_builds.where('stage_idx < ?', stage_idx) end - def timeout - project.build_timeout - end - def triggered_by?(current_user) user == current_user end @@ -248,31 +266,52 @@ module Ci Gitlab::Utils.slugify(ref.to_s) end - # Variables whose value does not depend on environment - def simple_variables - variables(environment: nil) - end - - # All variables, including those dependent on environment, which could - # contain unexpanded variables. - def variables(environment: persisted_environment) - collection = Gitlab::Ci::Variables::Collection.new.tap do |variables| + ## + # Variables in the environment name scope. + # + def scoped_variables(environment: expanded_environment_name) + Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.concat(predefined_variables) variables.concat(project.predefined_variables) variables.concat(pipeline.predefined_variables) variables.concat(runner.predefined_variables) if runner - variables.concat(project.deployment_variables(environment: environment)) if has_environment? + variables.concat(project.deployment_variables(environment: environment)) if environment variables.concat(yaml_variables) variables.concat(user_variables) - variables.concat(project.group.secret_variables_for(ref, project)) if project.group - variables.concat(secret_variables(environment: environment)) + variables.concat(secret_group_variables) + variables.concat(secret_project_variables(environment: environment)) variables.concat(trigger_request.user_variables) if trigger_request variables.concat(pipeline.variables) variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule - variables.concat(persisted_environment_variables) if environment end + end + + ## + # Variables that do not depend on the environment name. + # + def simple_variables + strong_memoize(:simple_variables) do + scoped_variables(environment: nil).to_runner_variables + end + end - collection.to_runner_variables + ## + # All variables, including persisted environment variables. + # + def variables + Gitlab::Ci::Variables::Collection.new + .concat(persisted_variables) + .concat(scoped_variables) + .concat(persisted_environment_variables) + .to_runner_variables + end + + ## + # Regular Ruby hash of scoped variables, without duplicates that are + # possible to be present in an array of hashes returned from `variables`. + # + def scoped_variables_hash + scoped_variables.to_hash end def features @@ -365,13 +404,19 @@ module Ci project.running_or_pending_build_count(force: true) end + def browsable_artifacts? + artifacts_metadata? + end + def artifacts_metadata_entry(path, **options) - metadata = Gitlab::Ci::Build::Artifacts::Metadata.new( - artifacts_metadata.path, - path, - **options) + artifacts_metadata.use_file do |metadata_path| + metadata = Gitlab::Ci::Build::Artifacts::Metadata.new( + metadata_path, + path, + **options) - metadata.to_entry + metadata.to_entry + end end def erase_artifacts! @@ -443,9 +488,14 @@ module Ci end end - def secret_variables(environment: persisted_environment) + def secret_group_variables + return [] unless project.group + + project.group.secret_variables_for(ref, project) + end + + def secret_project_variables(environment: persisted_environment) project.secret_variables_for(ref: ref, environment: environment) - .map(&:to_runner_variable) end def steps @@ -542,6 +592,21 @@ module Ci CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + def persisted_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted? + + variables + .append(key: 'CI_JOB_ID', value: id.to_s) + .append(key: 'CI_JOB_TOKEN', value: token, public: false) + .append(key: 'CI_BUILD_ID', value: id.to_s) + .append(key: 'CI_BUILD_TOKEN', value: token, public: false) + .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + .append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) + .append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) + end + end + def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI', value: 'true') @@ -550,16 +615,11 @@ module Ci variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION) - variables.append(key: 'CI_JOB_ID', value: id.to_s) variables.append(key: 'CI_JOB_NAME', value: name) variables.append(key: 'CI_JOB_STAGE', value: stage) - variables.append(key: 'CI_JOB_TOKEN', value: token, public: false) variables.append(key: 'CI_COMMIT_SHA', value: sha) variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) - variables.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) - variables.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false) - variables.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false) variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? @@ -567,23 +627,8 @@ module Ci end end - def persisted_environment_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - return variables unless persisted_environment - - variables.concat(persisted_environment.predefined_variables) - - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url - end - end - def legacy_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_BUILD_ID', value: id.to_s) - variables.append(key: 'CI_BUILD_TOKEN', value: token, public: false) variables.append(key: 'CI_BUILD_REF', value: sha) variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) variables.append(key: 'CI_BUILD_REF_NAME', value: ref) @@ -596,6 +641,19 @@ module Ci end end + def persisted_environment_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + return variables unless persisted? && persisted_environment.present? + + variables.concat(persisted_environment.predefined_variables) + + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url + end + end + def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb new file mode 100644 index 00000000000..96762f8845c --- /dev/null +++ b/app/models/ci/build_metadata.rb @@ -0,0 +1,35 @@ +module Ci + # The purpose of this class is to store Build related data that can be disposed. + # Data that should be persisted forever, should be stored with Ci::Build model. + class BuildMetadata < ActiveRecord::Base + extend Gitlab::Ci::Model + include Presentable + include ChronicDurationAttribute + + self.table_name = 'ci_builds_metadata' + + belongs_to :build, class_name: 'Ci::Build' + belongs_to :project + + validates :build, presence: true + validates :project, presence: true + + chronic_duration_attr_reader :timeout_human_readable, :timeout + + enum timeout_source: { + unknown_timeout_source: 1, + project_timeout_source: 2, + runner_timeout_source: 3 + } + + def update_timeout_state + return unless build.runner.present? + + project_timeout = project&.build_timeout + timeout = [project_timeout, build.runner.maximum_timeout].compact.min + timeout_source = timeout < project_timeout ? :runner_timeout_source : :project_timeout_source + + update(timeout: timeout, timeout_source: timeout_source) + end + end +end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 1dd0e050ba9..62d768cc6cf 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -6,6 +6,8 @@ module Ci belongs_to :group + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: :group_id, message: "(%{value}) has already been taken" diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 0a599f72bc7..df57b4f65e3 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -1,5 +1,7 @@ module Ci class JobArtifact < ActiveRecord::Base + include AfterCommitQueue + include ObjectStorage::BackgroundMove extend Gitlab::Ci::Model belongs_to :project @@ -7,9 +9,11 @@ module Ci before_save :set_size, if: :file_changed? + scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } + mount_uploader :file, JobArtifactUploader - delegate :open, :exists?, to: :file + delegate :exists?, :open, to: :file enum file_type: { archive: 1, @@ -21,6 +25,10 @@ module Ci self.where(project: project).sum(:size) end + def local_store? + [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store) + end + def set_size self.size = file.size end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 44f9bdf111e..434b9b64c65 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -6,6 +6,7 @@ module Ci include AfterCommitQueue include Presentable include Gitlab::OptimisticLocking + include Gitlab::Utils::StrongMemoize belongs_to :project, inverse_of: :pipelines belongs_to :user @@ -361,21 +362,23 @@ module Ci def stage_seeds return [] unless config_processor - @stage_seeds ||= config_processor.stage_seeds(self) + strong_memoize(:stage_seeds) do + seeds = config_processor.stages_attributes.map do |attributes| + Gitlab::Ci::Pipeline::Seed::Stage.new(self, attributes) + end + + seeds.select(&:included?) + end end def seeds_size - @seeds_size ||= stage_seeds.sum(&:size) + stage_seeds.sum(&:size) end def has_kubernetes_active? project.deployment_platform&.active? end - def has_stage_seeds? - stage_seeds.any? - end - def has_warnings? builds.latest.failed_but_allowed.any? end @@ -388,6 +391,9 @@ module Ci end end + ## + # TODO, setting yaml_errors should be moved to the pipeline creation chain. + # def config_processor return unless ci_yaml_file return @config_processor if defined?(@config_processor) @@ -472,6 +478,14 @@ module Ci end end + def protected_ref? + strong_memoize(:protected_ref) { project.protected_for?(ref) } + end + + def legacy_trigger + strong_memoize(:legacy_trigger) { trigger_requests.first } + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_PIPELINE_ID', value: id.to_s) diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index af989fb14b4..03df4e3e638 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -5,6 +5,8 @@ module Ci belongs_to :pipeline_schedule + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: :pipeline_schedule_id } end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 7173f88f1c7..5a4c56ec0dc 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -3,12 +3,13 @@ module Ci extend Gitlab::Ci::Model include Gitlab::SQL::Pattern include RedisCacheable + include ChronicDurationAttribute RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes AVAILABLE_SCOPES = %w[specific shared active paused online].freeze - FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -51,6 +52,12 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address + chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout + + validates :maximum_timeout, allow_nil: true, + numericality: { greater_than_or_equal_to: 600, + message: 'needs to be at least 10 minutes' } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 7c71291de84..452cb910bca 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -6,6 +6,8 @@ module Ci belongs_to :project + alias_attribute :secret_value, :value + validates :key, uniqueness: { scope: [:project_id, :environment_scope], message: "(%{value}) has already been taken" diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 49eb069016a..77947d515c1 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -10,6 +10,7 @@ module Clusters Applications::Prometheus.application_name => Applications::Prometheus, Applications::Runner.application_name => Applications::Runner }.freeze + DEFAULT_ENVIRONMENT = '*'.freeze belongs_to :user @@ -50,6 +51,11 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } + scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) } + scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) } + scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) } + + scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } def status_name if provider diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 7b7c8eac773..8f3eb75bfa9 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -4,6 +4,8 @@ module Clusters extend ActiveSupport::Concern included do + scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) } + state_machine :status, initial: :not_installable do state :not_installable, value: -2 state :errored, value: -1 diff --git a/app/models/commit.rb b/app/models/commit.rb index cceae5efb72..3f7f36e83c0 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -32,7 +32,8 @@ class Commit COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze def banzai_render_context(field) - context = { pipeline: :single_line, project: self.project } + pipeline = field == :description ? :commit_description : :single_line + context = { pipeline: pipeline, project: self.project } context[:author] = self.author if self.author context @@ -175,7 +176,7 @@ class Commit if safe_message.blank? no_commit_message else - safe_message.split("\n", 2).first + safe_message.split(/[\r\n]/, 2).first end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 9fb5b7efec6..3469d5d795c 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -141,7 +141,7 @@ class CommitStatus < ActiveRecord::Base end def group_name - name.to_s.gsub(%r{\d+[\.\s:/\\]+\d+\s*}, '').strip + name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip end def failed_but_allowed? diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 318df11727e..7677891b9ce 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -3,6 +3,7 @@ module Avatarable included do prepend ShadowMethods + include ObjectStorage::BackgroundMove validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb new file mode 100644 index 00000000000..593a9b3d71d --- /dev/null +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -0,0 +1,39 @@ +module ChronicDurationAttribute + extend ActiveSupport::Concern + + class_methods do + def chronic_duration_attr_reader(virtual_attribute, source_attribute) + define_method(virtual_attribute) do + chronic_duration_attributes[virtual_attribute] || output_chronic_duration_attribute(source_attribute) + end + end + + def chronic_duration_attr_writer(virtual_attribute, source_attribute, parameters = {}) + chronic_duration_attr_reader(virtual_attribute, source_attribute) + + define_method("#{virtual_attribute}=") do |value| + chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s + + begin + new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence + assign_attributes(source_attribute => new_value) + rescue ChronicDuration::DurationParseError + # ignore error as it will be caught by validation + end + end + + validates virtual_attribute, allow_nil: true, duration: true + end + + alias_method :chronic_duration_attr, :chronic_duration_attr_writer + end + + def chronic_duration_attributes + @chronic_duration_attributes ||= {} + end + + def output_chronic_duration_attribute(source_attribute) + value = attributes[source_attribute.to_s] + ChronicDuration.output(value, format: :short) if value + end +end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index faa94204e33..52851b3d0b2 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -1,16 +1,24 @@ module DeploymentPlatform - # EE would override this and utilize the extra argument + # EE would override this and utilize environment argument + # rubocop:disable Gitlab/ModuleWithInstanceVariables def deployment_platform(environment: nil) - @deployment_platform ||= - find_cluster_platform_kubernetes || - find_kubernetes_service_integration || - build_cluster_and_deployment_platform + @deployment_platform ||= {} + + @deployment_platform[environment] ||= find_deployment_platform(environment) end private - def find_cluster_platform_kubernetes - clusters.find_by(enabled: true)&.platform_kubernetes + def find_deployment_platform(environment) + find_cluster_platform_kubernetes(environment: environment) || + find_kubernetes_service_integration || + build_cluster_and_deployment_platform + end + + # EE would override this and utilize environment argument + def find_cluster_platform_kubernetes(environment: nil) + clusters.enabled.default_environment + .last&.platform_kubernetes end def find_kubernetes_service_integration diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5a566f3ac02..b45395343cc 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -137,7 +137,7 @@ module Issuable fuzzy_search(query, [:title, :description]) end - def sort(method, excluded_labels: []) + def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s when 'downvotes_desc' then order_downvotes_desc diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index caf8afa97f9..5130ecec472 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -45,11 +45,11 @@ module Milestoneish end def sorted_issues(user) - issues_visible_to_user(user).preload_associations.sort('label_priority') + issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') end def sorted_merge_requests - merge_requests.sort('label_priority') + merge_requests.sort_by_attribute('label_priority') end def upcoming? diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index c2e0a5fa126..89a74b7dcb1 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -27,6 +27,10 @@ class DeployKey < Key self.private? end + def user + super || User.ghost + end + def has_access_to?(project) deploy_keys_project_for(project).present? end diff --git a/app/models/event.rb b/app/models/event.rb index 17a198d52c7..3805f6cf857 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -52,12 +52,12 @@ class Event < ActiveRecord::Base belongs_to :target, -> { # If the association for "target" defines an "author" association we want to # eager-load this so Banzai & friends don't end up performing N+1 queries to - # get the authors of notes, issues, etc. - if reflections['events'].active_record.reflect_on_association(:author) - includes(:author) - else - self + # get the authors of notes, issues, etc. (likewise for "noteable"). + incs = %i(author noteable).select do |a| + reflections['events'].active_record.reflect_on_association(a) end + + incs.reduce(self) { |obj, a| obj.includes(a) } }, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations has_one :push_event_payload diff --git a/app/models/group.rb b/app/models/group.rb index d99af79b5fe..3cfe21ac93b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -53,7 +53,7 @@ class Group < Namespace Gitlab::Database.postgresql? end - def sort(method) + def sort_by_attribute(method) if method == 'storage_size_desc' # storage_size is a virtual column so we need to # pass a string to avoid AR adding the table name diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b6dd39b860b..ec072882cc9 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -7,6 +7,7 @@ class ProjectHook < WebHook :issue_hooks, :confidential_issue_hooks, :note_hooks, + :confidential_note_hooks, :merge_request_hooks, :job_hooks, :pipeline_hooks, diff --git a/app/models/issue.rb b/app/models/issue.rb index 7bfc45c1f43..13abc6c1a0d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -23,6 +23,7 @@ class Issue < ActiveRecord::Base belongs_to :project belongs_to :moved_to, class_name: 'Issue' + belongs_to :closed_by, class_name: 'User' has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } @@ -78,6 +79,11 @@ class Issue < ActiveRecord::Base before_transition any => :closed do |issue| issue.closed_at = Time.zone.now end + + before_transition closed: :opened do |issue| + issue.closed_at = nil + issue.closed_by = nil + end end class << self @@ -110,7 +116,7 @@ class Issue < ActiveRecord::Base 'project_id' end - def self.sort(method, excluded_labels: []) + def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'due_date' then order_due_date_asc when 'due_date_asc' then order_due_date_asc diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index b444812a4cf..b7de46fa202 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -1,15 +1,30 @@ class LfsObject < ActiveRecord::Base + include AfterCommitQueue + include ObjectStorage::BackgroundMove + has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :lfs_objects_projects + scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) } + validates :oid, presence: true, uniqueness: true mount_uploader :file, LfsObjectUploader + before_save :update_file_store + + def update_file_store + self.file_store = file.object_store + end + def project_allowed_access?(project) projects.exists?(project.lfs_storage_project.id) end + def local_store? + [nil, LfsObjectUploader::Store::LOCAL].include?(self.file_store) + end + def self.destroy_unreferenced joins("LEFT JOIN lfs_objects_projects ON lfs_objects_projects.lfs_object_id = #{table_name}.id") .where(lfs_objects_projects: { id: nil }) diff --git a/app/models/member.rb b/app/models/member.rb index e1a32148538..eac4a22a03f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -96,7 +96,7 @@ class Member < ActiveRecord::Base joins(:user).merge(User.search(query)) end - def sort(method) + def sort_by_attribute(method) case method.to_s when 'access_level_asc' then reorder(access_level: :asc) when 'access_level_desc' then reorder(access_level: :desc) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7e6d89ec9c7..91d8be5559b 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -536,18 +536,25 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end + def viewable_diffs + @viewable_diffs ||= merge_request_diffs.viewable.to_a + end + def merge_request_diff_for(diff_refs_or_sha) - @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha| - diffs = merge_request_diffs.viewable - h[diff_refs_or_sha] = - if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) - diffs.find_by_diff_refs(diff_refs_or_sha) - else - diffs.find_by(head_commit_sha: diff_refs_or_sha) - end - end + matcher = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + { + 'start_commit_sha' => diff_refs_or_sha.start_sha, + 'head_commit_sha' => diff_refs_or_sha.head_sha, + 'base_commit_sha' => diff_refs_or_sha.base_sha + } + else + { 'head_commit_sha' => diff_refs_or_sha } + end - @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] + viewable_diffs.find do |diff| + diff.attributes.slice(*matcher.keys) == matcher + end end def version_params_for(diff_refs) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index e7d397f40f5..dafae58d121 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -138,7 +138,7 @@ class Milestone < ActiveRecord::Base User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq end - def self.sort(method) + def self.sort_by_attribute(method) case method.to_s when 'due_date_asc' reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) diff --git a/app/models/note.rb b/app/models/note.rb index 787a80f0196..e426f84832b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -268,6 +268,10 @@ class Note < ActiveRecord::Base self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR end + def confidential? + noteable.try(:confidential?) + end + def editable? !system? end @@ -313,6 +317,10 @@ class Note < ActiveRecord::Base !system? && !for_snippet? end + def can_create_notification? + true + end + def discussion_class(noteable = nil) # When commit notes are rendered on an MR's Discussion page, they are # displayed in one discussion instead of individually. @@ -379,12 +387,15 @@ class Note < ActiveRecord::Base def expire_etag_cache return unless noteable&.discussions_rendered_on_frontend? - key = Gitlab::Routing.url_helpers.project_noteable_notes_path( + Gitlab::EtagCaching::Store.new.touch(etag_key) + end + + def etag_key + Gitlab::Routing.url_helpers.project_noteable_notes_path( project, target_type: noteable_type.underscore, target_id: noteable_id ) - Gitlab::EtagCaching::Store.new.touch(key) end def touch(*args) diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index e95655e19f8..b3ffad00a07 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -48,7 +48,7 @@ class NotificationRecipient when :custom custom_enabled? || %i[participating mention].include?(@type) when :watch, :participating - !excluded_watcher_action? + !action_excluded? when :mention @type == :mention else @@ -96,13 +96,22 @@ class NotificationRecipient end end + def action_excluded? + excluded_watcher_action? || excluded_participating_action? + end + def excluded_watcher_action? - return false unless @custom_action - return false if notification_level == :custom + return false unless @custom_action && notification_level == :watch NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action) end + def excluded_participating_action? + return false unless @custom_action && notification_level == :participating + + NotificationSetting::EXCLUDED_PARTICIPATING_EVENTS.include?(@custom_action) + end + private def read_ability diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 245f8dddcf9..f6d9b0215fc 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -33,6 +33,7 @@ class NotificationSetting < ActiveRecord::Base :close_issue, :reassign_issue, :new_merge_request, + :push_to_merge_request, :reopen_merge_request, :close_merge_request, :reassign_merge_request, @@ -41,10 +42,14 @@ class NotificationSetting < ActiveRecord::Base :success_pipeline ].freeze - EXCLUDED_WATCHER_EVENTS = [ + EXCLUDED_PARTICIPATING_EVENTS = [ :success_pipeline ].freeze + EXCLUDED_WATCHER_EVENTS = [ + :push_to_merge_request + ].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze + def self.find_or_create_for(source) setting = find_or_initialize_by(source: source) diff --git a/app/models/project.rb b/app/models/project.rb index 6a420663644..1b29cbf28d2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -21,6 +21,7 @@ class Project < ActiveRecord::Base include Gitlab::SQL::Pattern include DeploymentPlatform include ::Gitlab::Utils::StrongMemoize + include ChronicDurationAttribute extend Gitlab::ConfigHelper @@ -325,6 +326,12 @@ class Project < ActiveRecord::Base enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } + chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600 + + validates :build_timeout, allow_nil: true, + numericality: { greater_than_or_equal_to: 600, + message: 'needs to be at least 10 minutes' } + # Returns a collection of projects that is either public or visible to the # logged in user. def self.public_or_visible_to_user(user = nil) @@ -436,7 +443,7 @@ class Project < ActiveRecord::Base Gitlab::VisibilityLevel.options end - def sort(method) + def sort_by_attribute(method) case method.to_s when 'storage_size_desc' # storage_size is a joined column so we need to @@ -566,9 +573,7 @@ class Project < ActiveRecord::Base def add_import_job job_id = if forked? - RepositoryForkWorker.perform_async(id, - forked_from_project.repository_storage_path, - forked_from_project.disk_path) + RepositoryForkWorker.perform_async(id) elsif gitlab_project_import? # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-ce/issues/26189 is solved. RepositoryImportWorker.set(retry: false).perform_async(self.id) @@ -632,7 +637,7 @@ class Project < ActiveRecord::Base end def create_or_update_import_data(data: nil, credentials: nil) - return unless import_url.present? && valid_import_url? + return if data.nil? && credentials.nil? project_import_data = import_data || build_import_data if data @@ -1068,6 +1073,16 @@ class Project < ActiveRecord::Base end end + # This will return all `lfs_objects` that are accessible to the project. + # So this might be `self.lfs_objects` if the project is not part of a fork + # network, or it is the base of the fork network. + # + # TODO: refactor this to get the correct lfs objects when implementing + # https://gitlab.com/gitlab-org/gitlab-ce/issues/39769 + def all_lfs_objects + lfs_storage_project.lfs_objects + end + def personal? !group end @@ -1301,14 +1316,6 @@ class Project < ActiveRecord::Base self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end - def build_timeout_in_minutes - build_timeout / 60 - end - - def build_timeout_in_minutes=(value) - self.build_timeout = value.to_i * 60 - end - def open_issues_count Projects::OpenIssuesCountService.new(self).count end @@ -1346,20 +1353,19 @@ class Project < ActiveRecord::Base Dir.exist?(public_pages_path) end - def pages_url - subdomain, _, url_path = full_path.partition('/') - - # The hostname always needs to be in downcased - # All web servers convert hostname to lowercase - host = "#{subdomain}.#{Settings.pages.host}".downcase - + def pages_group_url # The host in URL always needs to be downcased - url = Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix| - "#{prefix}#{subdomain}." + Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix| + "#{prefix}#{pages_subdomain}." end.downcase + end + + def pages_url + url = pages_group_url + url_path = full_path.partition('/').last # If the project path is the same as host, we serve it as group page - return url if host == url_path + return url if url == "#{Settings.pages.protocol}://#{url_path}" "#{url}/#{url_path}" end @@ -1481,6 +1487,7 @@ class Project < ActiveRecord::Base remove_import_jid update_project_counter_caches after_create_default_branch + refresh_markdown_cache! end def update_project_counter_caches @@ -1545,8 +1552,8 @@ class Project < ActiveRecord::Base @errors = original_errors end - def add_export_job(current_user:, params: {}) - job_id = ProjectExportWorker.perform_async(current_user.id, self.id, params) + def add_export_job(current_user:, after_export_strategy: nil, params: {}) + job_id = ProjectExportWorker.perform_async(current_user.id, self.id, after_export_strategy, params) if job_id Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" @@ -1572,6 +1579,8 @@ class Project < ActiveRecord::Base def export_status if export_in_progress? :started + elsif after_export_in_progress? + :after_export_action elsif export_project_path :finished else @@ -1583,12 +1592,22 @@ class Project < ActiveRecord::Base import_export_shared.active_export_count > 0 end + def after_export_in_progress? + import_export_shared.after_export_in_progress? + end + def remove_exports return nil unless export_path.present? FileUtils.rm_rf(export_path) end + def remove_exported_project_file + return unless export_project_path.present? + + FileUtils.rm_f(export_project_path) + end + def full_path_slug Gitlab::Utils.slugify(full_path.to_s) end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index dab0ea1a681..7591ab4f478 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -21,8 +21,16 @@ class ChatNotificationService < Service end end + def confidential_issue_channel + properties['confidential_issue_channel'].presence || properties['issue_channel'] + end + + def confidential_note_channel + properties['confidential_note_channel'].presence || properties['note_channel'] + end + def self.supported_events - %w[push issue confidential_issue merge_request note tag_push + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end @@ -55,7 +63,9 @@ class ChatNotificationService < Service return false unless message - channel_name = get_channel_field(object_kind).presence || channel + event_type = data[:event_type] || object_kind + + channel_name = get_channel_field(event_type).presence || channel opts = {} opts[:channel] = channel_name if channel_name diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb index 017a9b2df6e..26cbfd784ad 100644 --- a/app/models/project_services/gemnasium_service.rb +++ b/app/models/project_services/gemnasium_service.rb @@ -36,7 +36,7 @@ class GemnasiumService < Service after: data[:after], token: token, api_key: api_key, - repo: project.repository.path_to_repo + repo: project.repository.path_to_repo # Gitaly: fixed by https://gitlab.com/gitlab-org/security-products/gemnasium-migration/issues/9 ) end end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index f31c3f02af2..dce878e485f 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -46,7 +46,7 @@ class HipchatService < Service end def self.supported_events - %w(push issue confidential_issue merge_request note tag_push pipeline) + %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) end def execute(data) diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index 20532527346..31de204d824 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -17,32 +17,4 @@ class RedirectRoute < ActiveRecord::Base where(wheres, path, "#{sanitize_sql_like(path)}/%") end - - scope :permanent, -> do - if column_permanent_exists? - where(permanent: true) - else - none - end - end - - scope :temporary, -> do - if column_permanent_exists? - where(permanent: [false, nil]) - else - all - end - end - - default_value_for :permanent, false - - def permanent=(value) - if self.class.column_permanent_exists? - super - end - end - - def self.column_permanent_exists? - ActiveRecord::Base.connection.column_exists?(:redirect_routes, :permanent) - end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 42f1ac43e29..fd1afafe4df 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -93,10 +93,6 @@ class Repository "#<#{self.class.name}:#{@disk_path}>" end - def create_hooks - Gitlab::Git::Repository.create_hooks(path_to_repo, Gitlab.config.gitlab_shell.hooks_path) - end - def commit(ref = 'HEAD') return nil unless exists? return ref if ref.is_a?(::Commit) @@ -253,13 +249,13 @@ class Repository end def diverging_commit_counts(branch) - root_ref_hash = raw_repository.commit(root_ref).id + @root_ref_hash ||= raw_repository.commit(root_ref).id cache.fetch(:"diverging_commit_counts_#{branch.name}") do # Rugged seems to throw a `ReferenceError` when given branch_names rather # than SHA-1 hashes number_commits_behind, number_commits_ahead = raw_repository.count_commits_between( - root_ref_hash, + @root_ref_hash, branch.dereferenced_target.sha, left_right: true, max_count: MAX_DIVERGING_COUNT) diff --git a/app/models/route.rb b/app/models/route.rb index 07d96c21cf1..2d609920051 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -10,8 +10,6 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - validate :ensure_permanent_paths, if: :path_changed? - before_validation :delete_conflicting_orphaned_routes after_create :delete_conflicting_redirects after_update :delete_conflicting_redirects, if: :path_changed? @@ -45,7 +43,7 @@ class Route < ActiveRecord::Base # We are not calling route.delete_conflicting_redirects here, in hopes # of avoiding deadlocks. The parent (self, in this method) already # called it, which deletes conflicts for all descendants. - route.create_redirect(old_path, permanent: permanent_redirect?) if attributes[:path] + route.create_redirect(old_path) if attributes[:path] end end end @@ -55,31 +53,17 @@ class Route < ActiveRecord::Base end def conflicting_redirects - RedirectRoute.temporary.matching_path_and_descendants(path) + RedirectRoute.matching_path_and_descendants(path) end - def create_redirect(path, permanent: false) - RedirectRoute.create(source: source, path: path, permanent: permanent) + def create_redirect(path) + RedirectRoute.create(source: source, path: path) end private def create_redirect_for_old_path - create_redirect(path_was, permanent: permanent_redirect?) if path_changed? - end - - def permanent_redirect? - source_type != "Project" - end - - def ensure_permanent_paths - return if path.nil? - - errors.add(:path, "has been taken before") if conflicting_redirect_exists? - end - - def conflicting_redirect_exists? - RedirectRoute.permanent.matching_path_and_descendants(path).exists? + create_redirect(path_was) if path_changed? end def delete_conflicting_orphaned_routes diff --git a/app/models/service.rb b/app/models/service.rb index 1dcb79157a2..e9b6f005aec 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -14,6 +14,7 @@ class Service < ActiveRecord::Base default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true + default_value_for :confidential_note_events, true default_value_for :job_events, true default_value_for :pipeline_events, true default_value_for :wiki_page_events, true @@ -42,6 +43,7 @@ class Service < ActiveRecord::Base scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } + scope :confidential_note_hooks, -> { where(confidential_note_events: true, active: true) } scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } @@ -168,8 +170,10 @@ class Service < ActiveRecord::Base def self.prop_accessor(*args) args.each do |arg| class_eval %{ - def #{arg} - properties['#{arg}'] + unless method_defined?(arg) + def #{arg} + properties['#{arg}'] + end end def #{arg}=(value) @@ -273,6 +277,7 @@ class Service < ActiveRecord::Base def self.build_from_template(project_id, template) service = template.dup + service.active = false unless service.valid? service.template = false service.project_id = project_id service diff --git a/app/models/todo.rb b/app/models/todo.rb index 8afacd188e0..a2ab405fdbe 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -50,7 +50,7 @@ class Todo < ActiveRecord::Base # Priority sorting isn't displayed in the dropdown, because we don't show # milestones, but still show something if the user has a URL with that # selected. - def sort(method) + def sort_by_attribute(method) sorted = case method.to_s when 'priority', 'label_priority' then order_by_labels_priority diff --git a/app/models/upload.rb b/app/models/upload.rb index 99ad37dc892..cf71a7b76fc 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -9,6 +9,8 @@ class Upload < ActiveRecord::Base validates :model, presence: true validates :uploader, presence: true + scope :with_files_stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) } + before_save :calculate_checksum!, if: :foreground_checksummable? after_commit :schedule_checksum, if: :checksummable? @@ -21,6 +23,7 @@ class Upload < ActiveRecord::Base end def absolute_path + raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? return path unless relative_path? uploader_class.absolute_path(self) @@ -30,11 +33,11 @@ class Upload < ActiveRecord::Base self.checksum = nil return unless checksummable? - self.checksum = self.class.hexdigest(absolute_path) + self.checksum = Digest::SHA256.file(absolute_path).hexdigest end - def build_uploader - uploader_class.new(model, mount_point, **uploader_context).tap do |uploader| + def build_uploader(mounted_as = nil) + uploader_class.new(model, mounted_as || mount_point).tap do |uploader| uploader.upload = self uploader.retrieve_from_store!(identifier) end @@ -51,6 +54,12 @@ class Upload < ActiveRecord::Base }.compact end + def local? + return true if store.nil? + + store == ObjectStorage::Store::LOCAL + end + private def delete_file! @@ -61,10 +70,6 @@ class Upload < ActiveRecord::Base checksum.nil? && local? && exist? end - def local? - true - end - def foreground_checksummable? checksummable? && size <= CHECKSUM_THRESHOLD end diff --git a/app/models/user.rb b/app/models/user.rb index b8c55205ab8..7b6857a0d34 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -82,11 +82,8 @@ class User < ActiveRecord::Base has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent # Profile - has_many :keys, -> do - type = Key.arel_table[:type] - where(type.not_eq('DeployKey').or(type.eq(nil))) - end, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :keys, -> { where(type: ['Key', nil]) }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :gpg_keys has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -167,12 +164,15 @@ class User < ActiveRecord::Base before_validation :sanitize_attrs before_validation :set_notification_email, if: :email_changed? + before_save :set_notification_email, if: :email_changed? # in case validation is skipped before_validation :set_public_email, if: :public_email_changed? + before_save :set_public_email, if: :public_email_changed? # in case validation is skipped before_save :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_validation :ensure_namespace_correct + before_save :ensure_namespace_correct # in case validation is skipped after_validation :set_username_errors after_update :username_changed_hook, if: :username_changed? after_destroy :post_destroy_hook @@ -187,7 +187,7 @@ class User < ActiveRecord::Base # User's Dashboard preference # Note: When adding an option, it MUST go on the end of the array. - enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos] + enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos, :issues, :merge_requests] # User's Project preference # Note: When adding an option, it MUST go on the end of the array. @@ -259,7 +259,7 @@ class User < ActiveRecord::Base end end - def sort(method) + def sort_by_attribute(method) order_method = method || 'id_desc' case order_method.to_s @@ -411,7 +411,6 @@ class User < ActiveRecord::Base unique_internal(where(ghost: true), 'ghost', email) do |u| u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.' u.name = 'Ghost User' - u.notification_email = email end end end @@ -623,9 +622,7 @@ class User < ActiveRecord::Base end def owned_projects - @owned_projects ||= - Project.where('namespace_id IN (?) OR namespace_id = ?', - owned_groups.select(:id), namespace.id).joins(:namespace) + @owned_projects ||= Project.from("(#{owned_projects_union.to_sql}) AS projects") end # Returns projects which user can admin issues on (for example to move an issue to that project). @@ -1196,6 +1193,15 @@ class User < ActiveRecord::Base private + def owned_projects_union + Gitlab::SQL::Union.new([ + Project.where(namespace: namespace), + Project.joins(:project_authorizations) + .where("projects.namespace_id <> ?", namespace.id) + .where(project_authorizations: { user_id: id, access_level: Gitlab::Access::OWNER }) + ], remove_duplicates: false) + end + def ci_projects_union scope = { access_level: [Gitlab::Access::MASTER, Gitlab::Access::OWNER] } groups = groups_projects.where(members: scope) diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 3f6d7d04667..e86d1c8f98e 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -2,20 +2,6 @@ class IssuablePolicy < BasePolicy delegate { @subject.project } condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? } - - # We aren't checking `:read_issue` or `:read_merge_request` in this case - # because it could be possible for a user to see an issuable-iid - # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be allowed - # to read the actual issue after a more expensive `:read_issue` check. - # - # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee. - condition(:visible_to_user, score: 4) do - Project.where(id: @subject.project) - .public_or_visible_to_user(@user) - .with_feature_available_for_user(@subject, @user) - .any? - end - condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) } desc "User is the assignee or author" diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index ed499511999..263c6e3039c 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -17,6 +17,4 @@ class IssuePolicy < IssuablePolicy prevent :update_issue prevent :admin_issue end - - rule { can?(:read_issue) | visible_to_user }.enable :read_issue_iid end diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index e003376d219..c3fe857f8a2 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -1,3 +1,2 @@ class MergeRequestPolicy < IssuablePolicy - rule { can?(:read_merge_request) | visible_to_user }.enable :read_merge_request_iid end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 57ab0c23dcd..b1ed034cd00 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -66,6 +66,22 @@ class ProjectPolicy < BasePolicy project.merge_requests_allowing_push_to_user(user).any? end + # We aren't checking `:read_issue` or `:read_merge_request` in this case + # because it could be possible for a user to see an issuable-iid + # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be + # allowed to read the actual issue after a more expensive `:read_issue` + # check. These checks are intended to be used alongside + # `:read_project_for_iids`. + # + # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee. + condition(:issues_visible_to_user, score: 4) do + @subject.feature_available?(:issues, @user) + end + + condition(:merge_requests_visible_to_user, score: 4) do + @subject.feature_available?(:merge_requests, @user) + end + features = %w[ merge_requests issues @@ -81,6 +97,10 @@ class ProjectPolicy < BasePolicy condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) } end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should + # not. + rule { guest | admin }.enable :read_project_for_iids + rule { guest }.enable :guest_access rule { reporter }.enable :reporter_access rule { developer }.enable :developer_access @@ -150,6 +170,7 @@ class ProjectPolicy < BasePolicy # where we enable or prevent it based on other coditions. rule { (~anonymous & public_project) | internal_access }.policy do enable :public_user_access + enable :read_project_for_iids end rule { can?(:public_user_access) }.policy do @@ -255,7 +276,11 @@ class ProjectPolicy < BasePolicy end rule { anonymous & ~public_project }.prevent_all - rule { public_project }.enable(:public_access) + + rule { public_project }.policy do + enable :public_access + enable :read_project_for_iids + end rule { can?(:public_access) }.policy do enable :read_project @@ -305,6 +330,14 @@ class ProjectPolicy < BasePolicy enable :update_pipeline end + rule do + (can?(:read_project_for_iids) & issues_visible_to_user) | can?(:read_issue) + end.enable :read_issue_iid + + rule do + (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) + end.enable :read_merge_request_iid + private def team_member? diff --git a/app/policies/protected_branch_policy.rb b/app/policies/protected_branch_policy.rb new file mode 100644 index 00000000000..1a7faa4db40 --- /dev/null +++ b/app/policies/protected_branch_policy.rb @@ -0,0 +1,9 @@ +class ProtectedBranchPolicy < BasePolicy + delegate { @subject.project } + + rule { can?(:admin_project) }.policy do + enable :create_protected_branch + enable :update_protected_branch + enable :destroy_protected_branch + end +end diff --git a/app/presenters/ci/build_metadata_presenter.rb b/app/presenters/ci/build_metadata_presenter.rb new file mode 100644 index 00000000000..5048f967ea8 --- /dev/null +++ b/app/presenters/ci/build_metadata_presenter.rb @@ -0,0 +1,18 @@ +module Ci + class BuildMetadataPresenter < Gitlab::View::Presenter::Delegated + TIMEOUT_SOURCES = { + unknown_timeout_source: nil, + project_timeout_source: 'project', + runner_timeout_source: 'runner' + }.freeze + + presents :metadata + + def timeout_source + return unless metadata.timeout_source? + + TIMEOUT_SOURCES[metadata.timeout_source.to_sym] || + metadata.timeout_source + end + end +end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 69d46f5ec14..ca4480fe2b1 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -5,6 +5,8 @@ class BuildDetailsEntity < JobEntity expose :runner, using: RunnerEntity expose :pipeline, using: PipelineEntity + expose :metadata, using: BuildMetadataEntity + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) diff --git a/app/serializers/build_metadata_entity.rb b/app/serializers/build_metadata_entity.rb new file mode 100644 index 00000000000..f16f3badffa --- /dev/null +++ b/app/serializers/build_metadata_entity.rb @@ -0,0 +1,6 @@ +class BuildMetadataEntity < Grape::Entity + expose :timeout_human_readable + expose :timeout_source do |metadata| + metadata.present.timeout_source + end +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index bbbcf6a97c1..718fb35e62d 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -4,7 +4,9 @@ class DiscussionEntity < Grape::Entity expose :id, :reply_id expose :expanded?, as: :expanded - expose :notes, using: NoteEntity + expose :notes do |discussion, opts| + request.note_entity.represent(discussion.notes, opts) + end expose :individual_note?, as: :individual_note expose :resolvable?, as: :resolvable @@ -12,7 +14,7 @@ class DiscussionEntity < Grape::Entity expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) end - expose :resolve_with_issue_path do |discussion| + expose :resolve_with_issue_path, if: -> (d, _) { d.resolvable? } do |discussion| new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 4ccf0bca476..c964aa9c99b 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -5,10 +5,6 @@ class NoteEntity < API::Entities::Note expose :author, using: NoteUserEntity - expose :human_access do |note| - note.project.team.human_max_access(note.author_id) - end - unexpose :note, as: :body expose :note @@ -37,36 +33,10 @@ class NoteEntity < API::Entities::Note expose :emoji_awardable?, as: :emoji_awardable expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity - expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| - if note.for_personal_snippet? - toggle_award_emoji_snippet_note_path(note.noteable, note) - else - toggle_award_emoji_project_note_path(note.project, note.id) - end - end expose :report_abuse_path do |note| new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) end - expose :path do |note| - if note.for_personal_snippet? - snippet_note_path(note.noteable, note) - else - project_note_path(note.project, note) - end - end - - expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) - end - - expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) - end - expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } - expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| - delete_attachment_project_note_path(note.project, note) - end end diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb deleted file mode 100644 index 2afe40d7a34..00000000000 --- a/app/serializers/note_serializer.rb +++ /dev/null @@ -1,3 +0,0 @@ -class NoteSerializer < BaseSerializer - entity NoteEntity -end diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb new file mode 100644 index 00000000000..e541bfbee8d --- /dev/null +++ b/app/serializers/project_note_entity.rb @@ -0,0 +1,25 @@ +class ProjectNoteEntity < NoteEntity + expose :human_access do |note| + note.project.team.human_max_access(note.author_id) + end + + expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| + toggle_award_emoji_project_note_path(note.project, note.id) + end + + expose :path do |note| + project_note_path(note.project, note) + end + + expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + end + + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) + end + + expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| + delete_attachment_project_note_path(note.project, note) + end +end diff --git a/app/serializers/project_note_serializer.rb b/app/serializers/project_note_serializer.rb new file mode 100644 index 00000000000..763ad0bdb3f --- /dev/null +++ b/app/serializers/project_note_serializer.rb @@ -0,0 +1,3 @@ +class ProjectNoteSerializer < BaseSerializer + entity ProjectNoteEntity +end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 3e40ecf1c1c..a7c2e21e92b 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -7,8 +7,14 @@ class StatusEntity < Grape::Entity expose :details_path expose :favicon do |status| - dir = 'ci_favicons' - dir = File.join(dir, 'dev') if Rails.env.development? + dir = + if Gitlab::Utils.to_boolean(ENV['CANARY']) + File.join('ci_favicons', 'canary') + elsif Rails.env.development? + File.join('ci_favicons', 'dev') + else + 'ci_favicons' + end ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 15fed7d17c1..3ceab209f3f 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -42,7 +42,10 @@ module Boards ) end - attrs[:move_between_ids] = move_between_ids if move_between_ids + if move_between_ids + attrs[:move_between_ids] = move_between_ids + attrs[:board_group_id] = board.group&.id + end attrs end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb index 6d0dd0a9f99..9269b8d2620 100644 --- a/app/services/boards/list_service.rb +++ b/app/services/boards/list_service.rb @@ -2,11 +2,15 @@ module Boards class ListService < Boards::BaseService def execute create_board! if parent.boards.empty? - parent.boards + boards end private + def boards + parent.boards + end + def create_board! Boards::CreateService.new(parent, current_user).execute end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index bebc90c7a8d..02f1c709374 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -12,11 +12,15 @@ module Boards private def available_labels_for(board) + options = { include_ancestor_groups: true } + if board.group_board? - parent.labels + options.merge!(group_id: parent.id, only_group_labels: true) else - LabelsFinder.new(current_user, project_id: parent.id).execute + options[:project_id] = parent.id end + + LabelsFinder.new(current_user, options).execute end def next_position(board) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ad27f320853..6ce86983287 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,6 +7,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Validate::Repository, Gitlab::Ci::Pipeline::Chain::Validate::Config, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::Create].freeze def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block) diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb deleted file mode 100644 index f2c175adee6..00000000000 --- a/app/services/ci/create_pipeline_stages_service.rb +++ /dev/null @@ -1,20 +0,0 @@ -module Ci - class CreatePipelineStagesService < BaseService - def execute(pipeline) - pipeline.stage_seeds.each do |seed| - seed.user = current_user - - seed.create! do |build| - ## - # Create the environment before the build starts. This sets its slug and - # makes it available as an environment variable - # - if build.has_environment? - environment_name = build.expanded_environment_name - project.environments.find_or_create_by(name: environment_name) - end - end - end - end - end -end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index a9813d774bb..85533a1cbdb 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -16,8 +16,8 @@ module Ci pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: params[:ref]) .execute(:trigger, ignore_skip_ci: true) do |pipeline| - pipeline.trigger_requests.create!(trigger: trigger) - create_pipeline_variables!(pipeline) + pipeline.trigger_requests.build(trigger: trigger) + pipeline.variables.build(variables) end if pipeline.persisted? @@ -33,14 +33,10 @@ module Ci end end - def create_pipeline_variables!(pipeline) - return unless params[:variables] - - variables = params[:variables].map do |key, value| + def variables + params[:variables].to_h.map do |key, value| { key: key, value: value } end - - pipeline.variables.create!(variables) end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 02fb48108fb..91ec702fbc6 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -106,7 +106,7 @@ class IssuableBaseService < BaseService end def available_labels - @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute + @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute end def handle_quick_actions_on_create(issuable) diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 0c5cf2c62ad..fee5bc38f7b 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -23,6 +23,7 @@ module Issues end if project.issues_enabled? && issue.close + issue.update(closed_by: current_user) event_service.close_issue(issue, current_user) create_note(issue, commit) if system_note notification_service.close_issue(issue, current_user) if notifications diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index d7aa7e2347e..4161932ad2a 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -55,9 +55,10 @@ module Issues return unless params[:move_between_ids] after_id, before_id = params.delete(:move_between_ids) + board_group_id = params.delete(:board_group_id) - issue_before = get_issue_if_allowed(issue.project, before_id) if before_id - issue_after = get_issue_if_allowed(issue.project, after_id) if after_id + issue_before = get_issue_if_allowed(before_id, board_group_id) + issue_after = get_issue_if_allowed(after_id, board_group_id) issue.move_between(issue_before, issue_after) end @@ -84,8 +85,16 @@ module Issues private - def get_issue_if_allowed(project, id) - issue = project.issues.find(id) + def get_issue_if_allowed(id, board_group_id = nil) + return unless id + + issue = + if board_group_id + IssuesFinder.new(current_user, group_id: board_group_id).find_by(id: id) + else + project.issues.find(id) + end + issue if can?(current_user, :update_issue, issue) end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 18c40ce8992..1fb1796b56c 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -21,7 +21,7 @@ module MergeRequests comment_mr_branch_presence_changed end - comment_mr_with_commits + notify_about_push mark_mr_as_wip_from_commits execute_mr_web_hooks @@ -141,8 +141,8 @@ module MergeRequests end end - # Add comment about pushing new commits to merge requests - def comment_mr_with_commits + # Add comment about pushing new commits to merge requests and send nofitication emails + def notify_about_push return unless @commits.present? merge_requests_for_source_branch.each do |merge_request| @@ -155,6 +155,8 @@ module MergeRequests SystemNoteService.add_commits(merge_request, merge_request.project, @current_user, new_commits, existing_commits, @oldrev) + + notification_service.push_to_merge_request(merge_request, @current_user, new_commits: new_commits, existing_commits: existing_commits) end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index ad3dcc5010b..199b8028dbc 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -11,7 +11,7 @@ module Notes unless @note.system? EventCreateService.new.leave_note(@note, @note.author) - return unless @note.for_project_noteable? + return if @note.for_personal_snippet? @note.create_cross_references! execute_note_hooks @@ -23,9 +23,13 @@ module Notes end def execute_note_hooks + return unless @note.project + note_data = hook_data - @note.project.execute_hooks(note_data, :note_hooks) - @note.project.execute_services(note_data, :note_hooks) + hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks + + @note.project.execute_hooks(note_data, hooks_scope) + @note.project.execute_services(note_data, hooks_scope) end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d7d2cde1004..f94c76cf3ac 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -113,6 +113,16 @@ class NotificationService new_resource_email(merge_request, :new_merge_request_email) end + def push_to_merge_request(merge_request, current_user, new_commits: [], existing_commits: []) + new_commits = new_commits.map { |c| { short_id: c.short_id, title: c.title } } + existing_commits = existing_commits.map { |c| { short_id: c.short_id, title: c.title } } + recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: "push_to") + + recipients.each do |recipient| + mailer.send(:push_to_merge_request_email, recipient.user.id, merge_request.id, current_user.id, recipient.reason, new_commits: new_commits, existing_commits: existing_commits).deliver_later + end + end + # When merge request text is updated, we should send an email to: # # * newly mentioned project team members with notification level higher than Participating diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index e61ecb696d0..346971138b1 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -21,7 +21,8 @@ module Projects end def labels(target = nil) - labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title]) + labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) + .execute.select([:color, :title]) return labels unless target&.respond_to?(:labels) diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 7fa1387084c..633e2c8236c 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -90,9 +90,6 @@ module Projects unless @project.gitlab_project_import? @project.write_repository_config @project.create_wiki unless skip_wiki? - create_services_from_active_templates(@project) - - @project.create_labels end event_service.create_project(@project, current_user) @@ -121,21 +118,29 @@ module Projects Project.transaction do @project.create_or_update_import_data(data: import_data[:data], credentials: import_data[:credentials]) if import_data - if @project.save && !@project.import? - raise 'Failed to create repository' unless @project.create_repository + if @project.save + unless @project.gitlab_project_import? + create_services_from_active_templates(@project) + @project.create_labels + end + + unless @project.import? + raise 'Failed to create repository' unless @project.create_repository + end end end end def fail(error:) message = "Unable to save project. Error: #{error}" - message << "Project ID: #{@project.id}" if @project && @project.id + log_message = message.dup - Rails.logger.error(message) + log_message << " Project ID: #{@project.id}" if @project&.id + Rails.logger.error(log_message) - if @project && @project.import? + if @project @project.errors.add(:base, message) - @project.mark_import_as_failed(message) + @project.mark_import_as_failed(message) if @project.import? end @project diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index a68ecb4abe1..fb4afb85588 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -5,8 +5,8 @@ module Projects class GitlabProjectsImportService attr_reader :current_user, :params - def initialize(user, params) - @current_user, @params = user, params.dup + def initialize(user, import_params, override_params = nil) + @current_user, @params, @override_params = user, import_params.dup, override_params end def execute @@ -17,6 +17,7 @@ module Projects params[:import_type] = 'gitlab_project' params[:import_source] = import_upload_path + params[:import_data] = { data: { override_params: @override_params } } if @override_params ::Projects::CreateService.new(current_user, params).execute end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index d16aa3de639..7bf0b90b491 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -1,22 +1,36 @@ module Projects module ImportExport class ExportService < BaseService - def execute(_options = {}) + def execute(after_export_strategy = nil, options = {}) @shared = project.import_export_shared - save_all + + save_all! + execute_after_export_action(after_export_strategy) end private - def save_all - if [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save) + def execute_after_export_action(after_export_strategy) + return unless after_export_strategy + + unless after_export_strategy.execute(current_user, project) + cleanup_and_notify_error + end + end + + def save_all! + if save_services Gitlab::ImportExport::Saver.save(project: project, shared: @shared) notify_success else - cleanup_and_notify + cleanup_and_notify_error! end end + def save_services + [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save) + end + def version_saver Gitlab::ImportExport::VersionSaver.new(shared: @shared) end @@ -41,19 +55,26 @@ module Projects Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared) end - def cleanup_and_notify + def lfs_saver + Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared) + end + + def cleanup_and_notify_error Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}") FileUtils.rm_rf(@shared.export_path) notify_error + end + + def cleanup_and_notify_error! + cleanup_and_notify_error + raise Gitlab::ImportExport::Error.new(@shared.errors.join(', ')) end def notify_success Rails.logger.info("Import/Export - Project #{project.name} with ID: #{project.id} successfully exported") - - notification_service.project_exported(@project, @current_user) end def notify_error diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index a34024f4f80..bdd9598f85a 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -28,7 +28,11 @@ module Projects def add_repository_to_project if project.external_import? && !unknown_url? - raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS) + begin + Gitlab::UrlBlocker.validate!(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + raise Error, "Blocked import URL: #{e.message}" + end end # We should skip the repository for a GitHub import or GitLab project import, @@ -57,7 +61,7 @@ module Projects project.ensure_repository project.repository.fetch_as_mirror(project.import_url, refmap: refmap) else - gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url) + gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url) end rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e # Expire cache to prevent scenarios such as: diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 00fdd047208..7e228d1833d 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -31,15 +31,17 @@ module Projects # Check if we did extract public directory archive_public_path = File.join(archive_path, 'public') - raise FailedToExtractError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) + raise InvaildStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) raise InvaildStateError, 'pages are outdated' unless latest? deploy_page!(archive_public_path) success end - rescue InvaildStateError, FailedToExtractError => e - register_failure + rescue InvaildStateError => e error(e.message) + rescue => e + error(e.message, false) + raise e end private @@ -50,12 +52,13 @@ module Projects super end - def error(message, http_status = nil) + def error(message, allow_delete_artifact = true) + register_failure log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message @status.drop(:script_failure) - delete_artifact! + delete_artifact! if allow_delete_artifact super end @@ -76,26 +79,28 @@ module Projects elsif artifacts.ends_with?('.zip') extract_zip_archive!(temp_path) else - raise FailedToExtractError, 'unsupported artifacts format' + raise InvaildStateError, 'unsupported artifacts format' end end def extract_tar_archive!(temp_path) - results = Open3.pipeline(%W(gunzip -c #{artifacts}), - %W(dd bs=#{BLOCK_SIZE} count=#{blocks}), - %W(tar -x -C #{temp_path} #{SITE_PATH}), - err: '/dev/null') - raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?) + build.artifacts_file.use_file do |artifacts_path| + results = Open3.pipeline(%W(gunzip -c #{artifacts_path}), + %W(dd bs=#{BLOCK_SIZE} count=#{blocks}), + %W(tar -x -C #{temp_path} #{SITE_PATH}), + err: '/dev/null') + raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?) + end end def extract_zip_archive!(temp_path) - raise FailedToExtractError, 'missing artifacts metadata' unless build.artifacts_metadata? + raise InvaildStateError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) if public_entry.total_size > max_size - raise FailedToExtractError, "artifacts for pages are too large: #{public_entry.total_size}" + raise InvaildStateError, "artifacts for pages are too large: #{public_entry.total_size}" end # Requires UnZip at least 6.00 Info-ZIP. @@ -103,8 +108,10 @@ module Projects # -n never overwrite existing files # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories site_path = File.join(SITE_PATH, '*') - unless system(*%W(unzip -qq -n #{artifacts} #{site_path} -d #{temp_path})) - raise FailedToExtractError, 'pages failed to extract' + build.artifacts_file.use_file do |artifacts_path| + unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path})) + raise FailedToExtractError, 'pages failed to extract' + end end end @@ -174,6 +181,9 @@ module Projects def latest_sha project.commit(build.ref).try(:sha).to_s + ensure + # Close any file descriptors that were opened and free libgit2 buffers + project.cleanup end def sha diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 6212fd69077..9d947f73af1 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -1,11 +1,20 @@ module ProtectedBranches class CreateService < BaseService - attr_reader :protected_branch - def execute(skip_authorization: false) - raise Gitlab::Access::AccessDeniedError unless skip_authorization || can?(current_user, :admin_project, project) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized? + + protected_branch.save + protected_branch + end + + def authorized? + can?(current_user, :create_protected_branch, protected_branch) + end + + private - project.protected_branches.create(params) + def protected_branch + @protected_branch ||= project.protected_branches.new(params) end end end diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb new file mode 100644 index 00000000000..8172c896e76 --- /dev/null +++ b/app/services/protected_branches/destroy_service.rb @@ -0,0 +1,9 @@ +module ProtectedBranches + class DestroyService < BaseService + def execute(protected_branch) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch) + + protected_branch.destroy + end + end +end diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index 4b3337a5c9d..95e46645374 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -1,7 +1,7 @@ module ProtectedBranches class UpdateService < BaseService def execute(protected_branch) - raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :update_protected_branch, protected_branch) protected_branch.update(params) protected_branch diff --git a/app/services/protected_tags/destroy_service.rb b/app/services/protected_tags/destroy_service.rb new file mode 100644 index 00000000000..c868d7ad8e6 --- /dev/null +++ b/app/services/protected_tags/destroy_service.rb @@ -0,0 +1,7 @@ +module ProtectedTags + class DestroyService < BaseService + def execute(protected_tag) + protected_tag.destroy + end + end +end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index cba49faac31..6cc51b6ee1b 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -200,7 +200,7 @@ module QuickActions end params '~label1 ~"label 2"' condition do - available_labels = LabelsFinder.new(current_user, project_id: project.id).execute + available_labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true).execute current_user.can?(:"admin_#{issuable.to_ability_name}", project) && available_labels.any? @@ -562,7 +562,7 @@ module QuickActions def find_labels(labels_param) extract_references(labels_param, :label) | - LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute end def find_label_references(labels_param) @@ -593,6 +593,7 @@ module QuickActions def extract_references(arg, type) ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) ext.references(type) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 2253d638e93..00bf5434b7f 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -429,7 +429,7 @@ module SystemNoteService def cross_reference(noteable, mentioner, author) return if cross_reference_disallowed?(noteable, mentioner) - gfm_reference = mentioner.gfm_reference(noteable.project) + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) body = cross_reference_note_content(gfm_reference) if noteable.is_a?(ExternalIssue) @@ -582,7 +582,7 @@ module SystemNoteService text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}" notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) else - gfm_reference = mentioner.gfm_reference(noteable.project) + gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) text = cross_reference_note_content(gfm_reference) notes.where(note: [text, text.capitalize]) end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index 86166047302..13cb53dee01 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -34,7 +34,8 @@ class VerifyPagesDomainService < BaseService # Prevent any pre-existing grace period from being truncated reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max - domain.update!(verified_at: Time.now, enabled_until: reverify) + domain.assign_attributes(verified_at: Time.now, enabled_until: reverify) + domain.save!(validate: false) if was_disabled notify(:enabled) @@ -47,7 +48,9 @@ class VerifyPagesDomainService < BaseService def unverify_domain! if domain.verified? - domain.update!(verified_at: nil) + domain.assign_attributes(verified_at: nil) + domain.save!(validate: false) + notify(:verification_failed) end @@ -55,7 +58,8 @@ class VerifyPagesDomainService < BaseService end def disable_domain! - domain.update!(verified_at: nil, enabled_until: nil) + domain.assign_attributes(verified_at: nil, enabled_until: nil) + domain.save!(validate: false) notify(:disabled) diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index 4930fb2fca7..cd819dc9bff 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -1,8 +1,8 @@ class AttachmentUploader < GitlabUploader - include UploaderHelper include RecordsUploads::Concern - - storage :file + include ObjectStorage::Concern + prepend ObjectStorage::Extension::RecordsUploads + include UploaderHelper private diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 5c8e1cea62e..5848e6c6994 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -1,18 +1,18 @@ class AvatarUploader < GitlabUploader include UploaderHelper include RecordsUploads::Concern - - storage :file + include ObjectStorage::Concern + prepend ObjectStorage::Extension::RecordsUploads def exists? model.avatar.file && model.avatar.file.present? end - def move_to_cache + def move_to_store false end - def move_to_store + def move_to_cache false end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index 8f56f09c9f7..bd7736ad74e 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -10,7 +10,11 @@ class FileMover def execute move - uploader.record_upload if update_markdown + + if update_markdown + uploader.record_upload + uploader.schedule_background_upload + end end private @@ -24,11 +28,8 @@ class FileMover updated_text = model.read_attribute(update_field) .gsub(temp_file_uploader.markdown_link, uploader.markdown_link) model.update_attribute(update_field, updated_text) - - true rescue revert - false end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index bde1161dfa8..133fdf6684d 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -9,14 +9,18 @@ class FileUploader < GitlabUploader include UploaderHelper include RecordsUploads::Concern + include ObjectStorage::Concern + prepend ObjectStorage::Extension::RecordsUploads MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)} DYNAMIC_PATH_PATTERN = %r{(?<secret>\h{32})/(?<identifier>.*)} - storage :file - after :remove, :prune_store_dir + # FileUploader do not run in a model transaction, so we can simply + # enqueue a job after the :store hook. + after :store, :schedule_background_upload + def self.root File.join(options.storage_path, 'uploads') end @@ -28,8 +32,11 @@ class FileUploader < GitlabUploader ) end - def self.base_dir(model) - model_path_segment(model) + def self.base_dir(model, store = Store::LOCAL) + decorated_model = model + decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE + + model_path_segment(decorated_model) end # used in migrations and import/exports @@ -47,21 +54,24 @@ class FileUploader < GitlabUploader # # Returns a String without a trailing slash def self.model_path_segment(model) - if model.hashed_storage?(:attachments) - model.disk_path + case model + when Storage::HashedProject then model.disk_path else - model.full_path + model.hashed_storage?(:attachments) ? model.disk_path : model.full_path end end - def self.upload_path(secret, identifier) - File.join(secret, identifier) - end - def self.generate_secret SecureRandom.hex end + def upload_paths(filename) + [ + File.join(secret, filename), + File.join(base_dir(Store::REMOTE), secret, filename) + ] + end + attr_accessor :model def initialize(model, mounted_as = nil, **uploader_context) @@ -71,8 +81,10 @@ class FileUploader < GitlabUploader apply_context!(uploader_context) end - def base_dir - self.class.base_dir(@model) + # enforce the usage of Hashed storage when storing to + # remote store as the FileMover doesn't support OS + def base_dir(store = nil) + self.class.base_dir(@model, store || object_store) end # we don't need to know the actual path, an uploader instance should be @@ -82,15 +94,19 @@ class FileUploader < GitlabUploader end def upload_path - self.class.upload_path(dynamic_segment, identifier) - end - - def model_path_segment - self.class.model_path_segment(@model) + if file_storage? + # Legacy path relative to project.full_path + File.join(dynamic_segment, identifier) + else + File.join(store_dir, identifier) + end end - def store_dir - File.join(base_dir, dynamic_segment) + def store_dirs + { + Store::LOCAL => File.join(base_dir, dynamic_segment), + Store::REMOTE => File.join(base_dir(ObjectStorage::Store::REMOTE), dynamic_segment) + } end def markdown_link diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 010100f2da1..f12f0466a1d 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -37,12 +37,10 @@ class GitlabUploader < CarrierWave::Uploader::Base cache_storage.is_a?(CarrierWave::Storage::File) end - # Reduce disk IO def move_to_cache file_storage? end - # Reduce disk IO def move_to_store file_storage? end @@ -51,10 +49,6 @@ class GitlabUploader < CarrierWave::Uploader::Base file.present? end - def store_dir - File.join(base_dir, dynamic_segment) - end - def cache_dir File.join(root, base_dir, 'tmp/cache') end @@ -76,6 +70,10 @@ class GitlabUploader < CarrierWave::Uploader::Base # Designed to be overridden by child uploaders that have a dynamic path # segment -- that is, a path that changes based on mutable attributes of its # associated model + # + # For example, `FileUploader` builds the storage path based on the associated + # project model's `path_with_namespace` value, which can change when the + # project or its containing namespace is moved or renamed. def dynamic_segment raise(NotImplementedError) end diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index ad5385f45a4..ef0f8acefd6 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -1,5 +1,6 @@ class JobArtifactUploader < GitlabUploader extend Workhorse::UploadPath + include ObjectStorage::Concern storage_options Gitlab.config.artifacts @@ -14,9 +15,11 @@ class JobArtifactUploader < GitlabUploader end def open - raise 'Only File System is supported' unless file_storage? - - File.open(path, "rb") if path + if file_storage? + File.open(path, "rb") if path + else + ::Gitlab::Ci::Trace::HttpIO.new(url, size) if url + end end private diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb index 28c458d3ff1..b726b053493 100644 --- a/app/uploaders/legacy_artifact_uploader.rb +++ b/app/uploaders/legacy_artifact_uploader.rb @@ -1,5 +1,6 @@ class LegacyArtifactUploader < GitlabUploader extend Workhorse::UploadPath + include ObjectStorage::Concern storage_options Gitlab.config.artifacts diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index e04c97ce179..eb521a22ebc 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -1,10 +1,6 @@ class LfsObjectUploader < GitlabUploader extend Workhorse::UploadPath - - # LfsObject are in `tmp/upload` instead of `tmp/uploads` - def self.workhorse_upload_path - File.join(root, 'tmp/upload') - end + include ObjectStorage::Concern storage_options Gitlab.config.lfs diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb index 993e85fbc13..1085ecb1700 100644 --- a/app/uploaders/namespace_file_uploader.rb +++ b/app/uploaders/namespace_file_uploader.rb @@ -4,7 +4,7 @@ class NamespaceFileUploader < FileUploader options.storage_path end - def self.base_dir(model) + def self.base_dir(model, _store = nil) File.join(options.base_dir, 'namespace', model_path_segment(model)) end @@ -14,6 +14,13 @@ class NamespaceFileUploader < FileUploader # Re-Override def store_dir - File.join(base_dir, dynamic_segment) + store_dirs[object_store] + end + + def store_dirs + { + Store::LOCAL => File.join(base_dir, dynamic_segment), + Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment) + } end end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb new file mode 100644 index 00000000000..3f59ee39299 --- /dev/null +++ b/app/uploaders/object_storage.rb @@ -0,0 +1,442 @@ +require 'fog/aws' +require 'carrierwave/storage/fog' + +# +# This concern should add object storage support +# to the GitlabUploader class +# +module ObjectStorage + RemoteStoreError = Class.new(StandardError) + UnknownStoreError = Class.new(StandardError) + ObjectStorageUnavailable = Class.new(StandardError) + + DIRECT_UPLOAD_TIMEOUT = 4.hours + TMP_UPLOAD_PATH = 'tmp/upload'.freeze + + module Store + LOCAL = 1 + REMOTE = 2 + end + + module Extension + # this extension is the glue between the ObjectStorage::Concern and RecordsUploads::Concern + module RecordsUploads + extend ActiveSupport::Concern + + def prepended(base) + raise "#{base} must include ObjectStorage::Concern to use extensions." unless base < Concern + + base.include(RecordsUploads::Concern) + end + + def retrieve_from_store!(identifier) + paths = store_dirs.map { |store, path| File.join(path, identifier) } + + unless current_upload_satisfies?(paths, model) + # the upload we already have isn't right, find the correct one + self.upload = uploads.find_by(model: model, path: paths) + end + + super + end + + def build_upload + super.tap do |upload| + upload.store = object_store + end + end + + def upload=(upload) + return unless upload + + self.object_store = upload.store + super + end + + def schedule_background_upload(*args) + return unless schedule_background_upload? + return unless upload + + ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name, + upload.class.to_s, + mounted_as, + upload.id) + end + + private + + def current_upload_satisfies?(paths, model) + return false unless upload + return false unless model + + paths.include?(upload.path) && + upload.model_id == model.id && + upload.model_type == model.class.base_class.sti_name + end + end + end + + # Add support for automatic background uploading after the file is stored. + # + module BackgroundMove + extend ActiveSupport::Concern + + def background_upload(mount_points = []) + return unless mount_points.any? + + run_after_commit do + mount_points.each { |mount| send(mount).schedule_background_upload } # rubocop:disable GitlabSecurity/PublicSend + end + end + + def changed_mounts + self.class.uploaders.select do |mount, uploader_class| + mounted_as = uploader_class.serialization_column(self.class, mount) + uploader = send(:"#{mounted_as}") # rubocop:disable GitlabSecurity/PublicSend + + next unless uploader + next unless uploader.exists? + next unless send(:"#{mounted_as}_changed?") # rubocop:disable GitlabSecurity/PublicSend + + mount + end.keys + end + + included do + after_save on: [:create, :update] do + background_upload(changed_mounts) + end + end + end + + module Concern + extend ActiveSupport::Concern + + included do |base| + base.include(ObjectStorage) + + after :migrate, :delete_migrated_file + end + + class_methods do + def object_store_options + options.object_store + end + + def object_store_enabled? + object_store_options.enabled + end + + def direct_upload_enabled? + object_store_options&.direct_upload + end + + def background_upload_enabled? + object_store_options.background_upload + end + + def proxy_download_enabled? + object_store_options.proxy_download + end + + def direct_download_enabled? + !proxy_download_enabled? + end + + def object_store_credentials + object_store_options.connection.to_hash.deep_symbolize_keys + end + + def remote_store_path + object_store_options.remote_directory + end + + def serialization_column(model_class, mount_point) + model_class.uploader_options.dig(mount_point, :mount_on) || mount_point + end + + def workhorse_authorize + if options = workhorse_remote_upload_options + { RemoteObject: options } + else + { TempPath: workhorse_local_upload_path } + end + end + + def workhorse_local_upload_path + File.join(self.root, TMP_UPLOAD_PATH) + end + + def workhorse_remote_upload_options + return unless self.object_store_enabled? + return unless self.direct_upload_enabled? + + id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-') + upload_path = File.join(TMP_UPLOAD_PATH, id) + connection = ::Fog::Storage.new(self.object_store_credentials) + expire_at = Time.now + DIRECT_UPLOAD_TIMEOUT + options = { 'Content-Type' => 'application/octet-stream' } + + { + ID: id, + GetURL: connection.get_object_url(remote_store_path, upload_path, expire_at), + DeleteURL: connection.delete_object_url(remote_store_path, upload_path, expire_at), + StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options) + } + end + + def default_object_store + if self.object_store_enabled? && self.direct_upload_enabled? + Store::REMOTE + else + Store::LOCAL + end + end + end + + # allow to configure and overwrite the filename + def filename + @filename || super || file&.filename # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def filename=(filename) + @filename = filename # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def file_storage? + storage.is_a?(CarrierWave::Storage::File) + end + + def file_cache_storage? + cache_storage.is_a?(CarrierWave::Storage::File) + end + + def object_store + @object_store ||= model.try(store_serialization_column) || self.class.default_object_store + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def object_store=(value) + @object_store = value || self.class.default_object_store + @storage = storage_for(object_store) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + # Return true if the current file is part or the model (i.e. is mounted in the model) + # + def persist_object_store? + model.respond_to?(:"#{store_serialization_column}=") + end + + # Save the current @object_store to the model <mounted_as>_store column + def persist_object_store! + return unless persist_object_store? + + updated = model.update_column(store_serialization_column, object_store) + raise 'Failed to update object store' unless updated + end + + def use_file(&blk) + with_exclusive_lease do + unsafe_use_file(&blk) + end + end + + # + # Move the file to another store + # + # new_store: Enum (Store::LOCAL, Store::REMOTE) + # + def migrate!(new_store) + with_exclusive_lease do + unsafe_migrate!(new_store) + end + end + + def schedule_background_upload(*args) + return unless schedule_background_upload? + + ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name, + model.class.name, + mounted_as, + model.id) + end + + def fog_directory + self.class.remote_store_path + end + + def fog_credentials + self.class.object_store_credentials + end + + def fog_public + false + end + + def delete_migrated_file(migrated_file) + migrated_file.delete if exists? + end + + def exists? + file.present? + end + + def store_dir(store = nil) + store_dirs[store || object_store] + end + + def store_dirs + { + Store::LOCAL => File.join(base_dir, dynamic_segment), + Store::REMOTE => File.join(dynamic_segment) + } + end + + def store_workhorse_file!(params, identifier) + filename = params["#{identifier}.name"] + + if remote_object_id = params["#{identifier}.remote_id"] + store_remote_file!(remote_object_id, filename) + elsif local_path = params["#{identifier}.path"] + store_local_file!(local_path, filename) + else + raise RemoteStoreError, 'Bad file' + end + end + + private + + def schedule_background_upload? + self.class.object_store_enabled? && + self.class.background_upload_enabled? && + self.file_storage? + end + + def store_remote_file!(remote_object_id, filename) + raise RemoteStoreError, 'Missing filename' unless filename + + file_path = File.join(TMP_UPLOAD_PATH, remote_object_id) + file_path = Pathname.new(file_path).cleanpath.to_s + raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/') + + self.object_store = Store::REMOTE + + # TODO: + # This should be changed to make use of `tmp/cache` mechanism + # instead of using custom upload directory, + # using tmp/cache makes this implementation way easier than it is today + CarrierWave::Storage::Fog::File.new(self, storage, file_path).tap do |file| + raise RemoteStoreError, 'Missing file' unless file.exists? + + self.filename = filename + self.file = storage.store!(file) + end + end + + def store_local_file!(local_path, filename) + raise RemoteStoreError, 'Missing filename' unless filename + + root_path = File.realpath(self.class.workhorse_local_upload_path) + file_path = File.realpath(local_path) + raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(root_path) + + self.object_store = Store::LOCAL + self.store!(UploadedFile.new(file_path, filename)) + end + + # this is a hack around CarrierWave. The #migrate method needs to be + # able to force the current file to the migrated file upon success. + def file=(file) + @file = file # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def serialization_column + self.class.serialization_column(model.class, mounted_as) + end + + # Returns the column where the 'store' is saved + # defaults to 'store' + def store_serialization_column + [serialization_column, 'store'].compact.join('_').to_sym + end + + def storage + @storage ||= storage_for(object_store) + end + + def storage_for(store) + case store + when Store::REMOTE + raise 'Object Storage is not enabled' unless self.class.object_store_enabled? + + CarrierWave::Storage::Fog.new(self) + when Store::LOCAL + CarrierWave::Storage::File.new(self) + else + raise UnknownStoreError + end + end + + def exclusive_lease_key + "object_storage_migrate:#{model.class}:#{model.id}" + end + + def with_exclusive_lease + uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain + raise 'exclusive lease already taken' unless uuid + + yield uuid + ensure + Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid) + end + + # + # Move the file to another store + # + # new_store: Enum (Store::LOCAL, Store::REMOTE) + # + def unsafe_migrate!(new_store) + return unless object_store != new_store + return unless file + + new_file = nil + file_to_delete = file + from_object_store = object_store + self.object_store = new_store # changes the storage and file + + cache_stored_file! if file_storage? + + with_callbacks(:migrate, file_to_delete) do + with_callbacks(:store, file_to_delete) do # for #store_versions! + new_file = storage.store!(file) + persist_object_store! + self.file = new_file + end + end + + file + rescue => e + # in case of failure delete new file + new_file.delete unless new_file.nil? + # revert back to the old file + self.object_store = from_object_store + self.file = file_to_delete + raise e + end + end + + def unsafe_use_file + if file_storage? + return yield path + end + + begin + cache_stored_file! + yield cache_path + ensure + FileUtils.rm_f(cache_path) + cache_storage.delete_dir!(cache_path(nil)) + end + end +end diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb index f2ad0badd53..e3898b07730 100644 --- a/app/uploaders/personal_file_uploader.rb +++ b/app/uploaders/personal_file_uploader.rb @@ -4,7 +4,7 @@ class PersonalFileUploader < FileUploader options.storage_path end - def self.base_dir(model) + def self.base_dir(model, _store = nil) File.join(options.base_dir, model_path_segment(model)) end @@ -14,6 +14,12 @@ class PersonalFileUploader < FileUploader File.join(model.class.to_s.underscore, model.id.to_s) end + def object_store + return Store::LOCAL unless model + + super + end + # model_path_segment does not require a model to be passed, so we can always # generate a path, even when there's no model. def model_valid? @@ -22,7 +28,14 @@ class PersonalFileUploader < FileUploader # Revert-Override def store_dir - File.join(base_dir, dynamic_segment) + store_dirs[object_store] + end + + def store_dirs + { + Store::LOCAL => File.join(base_dir, dynamic_segment), + Store::REMOTE => File.join(self.class.model_path_segment(model), dynamic_segment) + } end private diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 458928bc067..89c74a78835 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -24,8 +24,7 @@ module RecordsUploads uploads.where(path: upload_path).delete_all upload.destroy! if upload - self.upload = build_upload - upload.save! + self.upload = build_upload.tap(&:save!) end end diff --git a/app/validators/certificate_fingerprint_validator.rb b/app/validators/certificate_fingerprint_validator.rb new file mode 100644 index 00000000000..17df756183a --- /dev/null +++ b/app/validators/certificate_fingerprint_validator.rb @@ -0,0 +1,9 @@ +class CertificateFingerprintValidator < ActiveModel::EachValidator + FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/.freeze + + def validate_each(record, attribute, value) + unless value.try(:match, FINGERPRINT_PATTERN) + record.errors.add(attribute, "must be a hash containing only letters, numbers, spaces, : and -") + end + end +end diff --git a/app/validators/importable_url_validator.rb b/app/validators/importable_url_validator.rb index 3ec1594e202..612d3c71913 100644 --- a/app/validators/importable_url_validator.rb +++ b/app/validators/importable_url_validator.rb @@ -4,8 +4,8 @@ # protect against Server-side Request Forgery (SSRF). class ImportableUrlValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if Gitlab::UrlBlocker.blocked_url?(value, valid_ports: Project::VALID_IMPORT_PORTS) - record.errors.add(attribute, "imports are not allowed from that URL") - end + Gitlab::UrlBlocker.validate!(value, valid_ports: Project::VALID_IMPORT_PORTS) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + record.errors.add(attribute, "is blocked: #{e.message}") end end diff --git a/app/validators/top_level_group_validator.rb b/app/validators/top_level_group_validator.rb new file mode 100644 index 00000000000..7e2e735e0cf --- /dev/null +++ b/app/validators/top_level_group_validator.rb @@ -0,0 +1,7 @@ +class TopLevelGroupValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if value&.subgroup? + record.errors.add(attribute, "must be a top level Group") + end + end +end diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml new file mode 100644 index 00000000000..bb3fa26a33e --- /dev/null +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -0,0 +1,12 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :admin_notification_email, class: 'form-control' + .help-block + Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml new file mode 100644 index 00000000000..dd86c9ed2eb --- /dev/null +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -0,0 +1,39 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :gravatar_enabled do + = f.check_box :gravatar_enabled + Gravatar enabled + .form-group + = f.label :default_projects_limit, class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :default_projects_limit, class: 'form-control' + .form-group + = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :max_attachment_size, class: 'form-control' + .form-group + = f.label :session_expire_delay, 'Session duration (minutes)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :session_expire_delay, class: 'form-control' + %span.help-block#session_expire_delay_help_block GitLab restart is required to apply changes + .form-group + = f.label :user_oauth_applications, 'User OAuth applications', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :user_oauth_applications do + = f.check_box :user_oauth_applications + Allow users to register any application to use GitLab as an OAuth provider + .form-group + = f.label :user_default_external, 'New users set to external', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :user_default_external do + = f.check_box :user_default_external + Newly registered users will by default be external + + = f.submit 'Save changes', class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_background_jobs.html.haml b/app/views/admin/application_settings/_background_jobs.html.haml new file mode 100644 index 00000000000..8198a822a10 --- /dev/null +++ b/app/views/admin/application_settings/_background_jobs.html.haml @@ -0,0 +1,30 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %p + These settings require a + = link_to 'restart', help_page_path('administration/restart_gitlab') + to take effect. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :sidekiq_throttling_enabled do + = f.check_box :sidekiq_throttling_enabled + Enable Sidekiq Job Throttling + .help-block + Limit the amount of resources slow running jobs are assigned. + .form-group + = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } + .help-block + Choose which queues you wish to throttle. + .form-group + = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' + .help-block + The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml new file mode 100644 index 00000000000..b4d2a789df0 --- /dev/null +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -0,0 +1,47 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :auto_devops_enabled do + = f.check_box :auto_devops_enabled + Enabled Auto DevOps (Beta) for projects by default + .help-block + It will automatically build, test, and deploy applications based on a predefined CI/CD configuration + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') + .form-group + = f.label :auto_devops_domain, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' + .help-block + = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :shared_runners_enabled do + = f.check_box :shared_runners_enabled + Enable shared runners for new projects + .form-group + = f.label :shared_runners_text, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :shared_runners_text, class: 'form-control', rows: 4 + .help-block Markdown enabled + .form-group + = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :max_artifacts_size, class: 'form-control' + .help-block + Set the maximum file size for each job's artifacts + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') + .form-group + = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :default_artifacts_expire_in, class: 'form-control' + .help-block + Set the default expiration time for each job's artifacts. + 0 for unlimited. + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml new file mode 100644 index 00000000000..6c89f1c4e98 --- /dev/null +++ b/app/views/admin/application_settings/_email.html.haml @@ -0,0 +1,26 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :email_author_in_body do + = f.check_box :email_author_in_body + Include author name in notification email body + .help-block + Some email servers do not support overriding the email sender name. + Enable this option to include the name of the author of the issue, + merge request or comment in the email body instead. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :html_emails_enabled do + = f.check_box :html_emails_enabled + Enable HTML emails + .help-block + By default GitLab sends emails in HTML and plain text formats so mail + clients can choose what format to use. Disable this option if you only + want to send emails in plain text format. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml deleted file mode 100644 index 54b39df8cf3..00000000000 --- a/app/views/admin/application_settings/_form.html.haml +++ /dev/null @@ -1,873 +0,0 @@ -= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| - = form_errors(@application_setting) - - %fieldset - %legend Visibility and Access Controls - .form-group - = f.label :default_branch_protection, class: 'control-label col-sm-2' - .col-sm-10 - = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' - .form-group.visibility-level-setting - = f.label :default_project_visibility, class: 'control-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) - .form-group.visibility-level-setting - = f.label :default_snippet_visibility, class: 'control-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) - .form-group.visibility-level-setting - = f.label :default_group_visibility, class: 'control-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) - .form-group - = f.label :restricted_visibility_levels, class: 'control-label col-sm-2' - .col-sm-10 - - checkbox_name = 'application_setting[restricted_visibility_levels][]' - = hidden_field_tag(checkbox_name) - - restricted_level_checkboxes('restricted-visibility-help', checkbox_name).each do |level| - .checkbox - = level - %span.help-block#restricted-visibility-help - Selected levels cannot be used by non-admin users for projects or snippets. - If the public level is restricted, user profiles are only visible to logged in users. - .form-group - = f.label :import_sources, class: 'control-label col-sm-2' - .col-sm-10 - - import_sources_checkboxes('import-sources-help').each do |source| - .checkbox= source - %span.help-block#import-sources-help - Enabled sources for code import during project creation. OmniAuth must be configured for GitHub - = link_to "(?)", help_page_path("integration/github") - , Bitbucket - = link_to "(?)", help_page_path("integration/bitbucket") - and GitLab.com - = link_to "(?)", help_page_path("integration/gitlab") - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :project_export_enabled do - = f.check_box :project_export_enabled - Project export enabled - - .form-group - %label.control-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.help-block#clone-protocol-help - Allow only the selected protocols to be used for Git access. - - - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| - - field_name = :"#{type}_key_restriction" - .form-group - = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2' - .col-sm-10 - = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' - - %fieldset - %legend Account and Limit Settings - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :gravatar_enabled do - = f.check_box :gravatar_enabled - Gravatar enabled - .form-group - = f.label :default_projects_limit, class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :default_projects_limit, class: 'form-control' - .form-group - = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :max_attachment_size, class: 'form-control' - .form-group - = f.label :session_expire_delay, 'Session duration (minutes)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :session_expire_delay, class: 'form-control' - %span.help-block#session_expire_delay_help_block GitLab restart is required to apply changes - .form-group - = f.label :user_oauth_applications, 'User OAuth applications', class: 'control-label col-sm-2' - .col-sm-10 - .checkbox - = f.label :user_oauth_applications do - = f.check_box :user_oauth_applications - Allow users to register any application to use GitLab as an OAuth provider - .form-group - = f.label :user_default_external, 'New users set to external', class: 'control-label col-sm-2' - .col-sm-10 - .checkbox - = f.label :user_default_external do - = f.check_box :user_default_external - Newly registered users will by default be external - - %fieldset - %legend Sign-up Restrictions - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :signup_enabled do - = f.check_box :signup_enabled - Sign-up enabled - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :send_user_confirmation_email do - = f.check_box :send_user_confirmation_email - Send confirmation email on sign-up - .form-group - = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 - .help-block ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com - .form-group - = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'control-label col-sm-2' - .col-sm-10 - .checkbox - = f.label :domain_blacklist_enabled do - = f.check_box :domain_blacklist_enabled - Enable domain blacklist for sign ups - .form-group - .col-sm-offset-2.col-sm-10 - .radio - = label_tag :blacklist_type_file do - = radio_button_tag :blacklist_type, :file - .option-title - Upload blacklist file - .radio - = label_tag :blacklist_type_raw do - = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank? - .option-title - Enter blacklist manually - .form-group.blacklist-file - = f.label :domain_blacklist_file, 'Blacklist file', class: 'control-label col-sm-2' - .col-sm-10 - = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf' - .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries. - .form-group.blacklist-raw - = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 - .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com - - .form-group - = f.label :after_sign_up_text, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 - .help-block Markdown enabled - - %fieldset - %legend Sign-in Restrictions - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :password_authentication_enabled_for_web do - = f.check_box :password_authentication_enabled_for_web - Password authentication enabled for web interface - .help-block - When disabled, an external authentication provider must be used. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :password_authentication_enabled_for_git do - = f.check_box :password_authentication_enabled_for_git - Password authentication enabled for Git over HTTP(S) - .help-block - When disabled, a Personal Access Token - - if Gitlab::Auth::LDAP::Config.enabled? - or LDAP password - must be used to authenticate. - - if omniauth_enabled? && button_based_providers.any? - .form-group - = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' - .col-sm-10 - .btn-group{ data: { toggle: 'buttons' } } - - oauth_providers_checkboxes.each do |source| - = source - .form-group - = f.label :two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2' - .col-sm-10 - .checkbox - = f.label :require_two_factor_authentication do - = f.check_box :require_two_factor_authentication - Require all users to setup Two-factor authentication - .form-group - = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0' - .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication - .form-group - = f.label :home_page_url, 'Home page URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' - %span.help-block#home_help_block We will redirect non-logged in users to this page - .form-group - = f.label :after_sign_out_path, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' - %span.help-block#after_sign_out_path_help_block We will redirect users to this page after they sign out - .form-group - = f.label :sign_in_text, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :sign_in_text, class: 'form-control', rows: 4 - .help-block Markdown enabled - - %fieldset - %legend Help Page - .form-group - = f.label :help_page_text, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :help_page_text, class: 'form-control', rows: 4 - .help-block Markdown enabled - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :help_page_hide_commercial_content do - = f.check_box :help_page_hide_commercial_content - Hide marketing-related entries from help - .form-group - = f.label :help_page_support_url, 'Support page URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' - %span.help-block#support_help_block Alternate support URL for help page - - %fieldset - %legend Pages - .form-group - = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :max_pages_size, class: 'form-control' - .help-block 0 for unlimited - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :pages_domain_verification_enabled do - = f.check_box :pages_domain_verification_enabled - Require users to prove ownership of custom domains - .help-block - Domain verification is an essential security measure for public GitLab - sites. Users are required to demonstrate they control a domain before - it is enabled - = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') - - %fieldset - %legend Continuous Integration and Deployment - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :auto_devops_enabled do - = f.check_box :auto_devops_enabled - Enabled Auto DevOps (Beta) for projects by default - .help-block - It will automatically build, test, and deploy applications based on a predefined CI/CD configuration - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') - .form-group - = f.label :auto_devops_domain, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' - .help-block - = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :shared_runners_enabled do - = f.check_box :shared_runners_enabled - Enable shared runners for new projects - .form-group - = f.label :shared_runners_text, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_area :shared_runners_text, class: 'form-control', rows: 4 - .help-block Markdown enabled - .form-group - = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :max_artifacts_size, class: 'form-control' - .help-block - Set the maximum file size for each job's artifacts - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') - .form-group - = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :default_artifacts_expire_in, class: 'form-control' - .help-block - Set the default expiration time for each job's artifacts. - 0 for unlimited. - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') - - - if Gitlab.config.registry.enabled - %fieldset - %legend Container Registry - .form-group - = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :container_registry_token_expire_delay, class: 'form-control' - - %fieldset - %legend Metrics - Influx - %p - Setup InfluxDB to measure a wide variety of statistics like the time spent - in running SQL queries. These settings require a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. - = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction') - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :metrics_enabled do - = f.check_box :metrics_enabled - Enable InfluxDB Metrics - .form-group - = f.label :metrics_host, 'InfluxDB host', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com' - .form-group - = f.label :metrics_port, 'InfluxDB port', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :metrics_port, class: 'form-control', placeholder: '8089' - .help-block - The UDP port to use for connecting to InfluxDB. InfluxDB requires that - your server configuration specifies a database to store data in when - sending messages to this port, without it metrics data will not be - saved. - .form-group - = f.label :metrics_pool_size, 'Connection pool size', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_pool_size, class: 'form-control' - .help-block - The amount of InfluxDB connections to open. Connections are opened - lazily. Users using multi-threaded application servers should ensure - enough connections are available (at minimum the amount of application - server threads). - .form-group - = f.label :metrics_timeout, 'Connection timeout', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_timeout, class: 'form-control' - .help-block - The amount of seconds after which an InfluxDB connection will time - out. - .form-group - = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_method_call_threshold, class: 'form-control' - .help-block - A method call is only tracked when it takes longer to complete than - the given amount of milliseconds. - .form-group - = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_sample_interval, class: 'form-control' - .help-block - The sampling interval in seconds. Sampled data includes memory usage, - retained Ruby objects, file descriptors and so on. - .form-group - = f.label :metrics_packet_size, 'Metrics per packet', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_packet_size, class: 'form-control' - .help-block - The amount of points to store in a single UDP packet. More points - results in fewer but larger UDP packets being sent. - - %fieldset - %legend Metrics - Prometheus - %p - Enable a Prometheus metrics endpoint at - %code= metrics_path - to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available - = link_to 'here', admin_health_check_path - \. This setting requires a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. - = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/index') - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :prometheus_metrics_enabled do - = f.check_box :prometheus_metrics_enabled - Enable Prometheus Metrics - - unless Gitlab::Metrics.metrics_folder_present? - .help-block - %strong.cred WARNING: - Environment variable - %code prometheus_multiproc_dir - does not exist or is not pointing to a valid directory. - = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') - - %fieldset - %legend Profiling - Performance Bar - %p - Enable the Performance Bar for a given group. - = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :performance_bar_enabled do - = f.check_box :performance_bar_enabled - Enable the Performance Bar - .form-group - = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - - %fieldset - %legend Background Jobs - %p - These settings require a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :sidekiq_throttling_enabled do - = f.check_box :sidekiq_throttling_enabled - Enable Sidekiq Job Throttling - .help-block - Limit the amount of resources slow running jobs are assigned. - .form-group - = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2' - .col-sm-10 - = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } - .help-block - Choose which queues you wish to throttle. - .form-group - = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' - .help-block - The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. - - %fieldset - %legend Spam and Anti-bot Protection - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :recaptcha_enabled do - = f.check_box :recaptcha_enabled - Enable reCAPTCHA - %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts - - .form-group - = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_site_key, class: 'form-control' - .help-block - Generate site and private keys at - %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha - - .form-group - = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_private_key, class: 'form-control' - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :akismet_enabled do - = f.check_box :akismet_enabled - Enable Akismet - %span.help-block#akismet_help_block Helps prevent bots from creating issues - - .form-group - = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :akismet_api_key, class: 'form-control' - .help-block - Generate API key at - %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :unique_ips_limit_enabled do - = f.check_box :unique_ips_limit_enabled - Limit sign in from multiple ips - %span.help-block#unique_ip_help_block - Helps prevent malicious users hide their activity - - .form-group - = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_per_user, class: 'form-control' - .help-block - Maximum number of unique IPs per user - - .form-group - = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_time_window, class: 'form-control' - .help-block - How many seconds an IP will be counted towards the limit - - %fieldset - %legend Abuse reports - .form-group - = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :admin_notification_email, class: 'form-control' - .help-block - Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. - - %fieldset - %legend Error Reporting and Logging - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :sentry_enabled do - = f.check_box :sentry_enabled - Enable Sentry - .help-block - %p This setting requires a restart to take effect. - Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: - %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com - - .form-group - = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :sentry_dsn, class: 'form-control' - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :clientside_sentry_enabled do - = f.check_box :clientside_sentry_enabled - Enable Clientside Sentry - .help-block - Sentry can also be used for reporting and logging clientside exceptions. - %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ - - .form-group - = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :clientside_sentry_dsn, class: 'form-control' - - %fieldset - %legend Repository Storage - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :hashed_storage_enabled do - = f.check_box :hashed_storage_enabled - Create new projects using hashed storage paths - .help-block - Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents - repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. - %em (EXPERIMENTAL) - .form-group - = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' - .col-sm-10 - = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), - {include_hidden: false}, multiple: true, class: 'form-control' - .help-block - Manage repository storage paths. Learn more in the - = succeed "." do - = link_to "repository storages documentation", help_page_path("administration/repository_storages") - - %fieldset - %legend Git Storage Circuitbreaker settings - .form-group - = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_check_interval, class: 'form-control' - .help-block - = circuitbreaker_check_interval_help_text - .form-group - = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_access_retries, class: 'form-control' - .help-block - = circuitbreaker_access_retries_help_text - .form-group - = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' - .help-block - = circuitbreaker_storage_timeout_help_text - .form-group - = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' - .help-block - = circuitbreaker_failure_count_help_text - .form-group - = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' - .help-block - = circuitbreaker_failure_reset_time_help_text - - %fieldset - %legend Repository Checks - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :repository_checks_enabled do - = f.check_box :repository_checks_enabled - Enable Repository Checks - .help-block - GitLab will periodically run - %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck' - in all project and wiki repositories to look for silent disk corruption issues. - .form-group - .col-sm-offset-2.col-sm-10 - = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" - .help-block - If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. - - - if koding_enabled? - %fieldset - %legend Koding - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :koding_enabled do - = f.check_box :koding_enabled - Enable Koding - .help-block - Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. - .form-group - = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' - .help-block - Koding has integration enabled out of the box for the - %strong gitlab - team, and you need to provide that team's URL here. Learn more in the - = succeed "." do - = link_to "Koding administration documentation", help_page_path("administration/integration/koding") - - %fieldset - %legend PlantUML - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :plantuml_enabled do - = f.check_box :plantuml_enabled - Enable PlantUML - .form-group - = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' - .help-block - Allow rendering of - = link_to "PlantUML", "http://plantuml.com" - diagrams in Asciidoc documents using an external PlantUML service. - - %fieldset - %legend#usage-statistics Usage statistics - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :version_check_enabled do - = f.check_box :version_check_enabled - Enable version check - .help-block - GitLab will inform you if a new version is available. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") - about what information is shared with GitLab Inc. - .form-group - .col-sm-offset-2.col-sm-10 - - can_be_configured = @application_setting.usage_ping_can_be_configured? - .checkbox - = f.label :usage_ping_enabled do - = f.check_box :usage_ping_enabled, disabled: !can_be_configured - Enable usage ping - .help-block - - if can_be_configured - To help improve GitLab and its user experience, GitLab will - periodically collect usage information. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") - about what information is shared with GitLab Inc. Visit - = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') - to see the JSON payload sent. - - else - The usage ping is disabled, and cannot be configured through this - form. For more information, see the documentation on - = succeed '.' do - = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') - - %fieldset - %legend Email - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :email_author_in_body do - = f.check_box :email_author_in_body - Include author name in notification email body - .help-block - Some email servers do not support overriding the email sender name. - Enable this option to include the name of the author of the issue, - merge request or comment in the email body instead. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :html_emails_enabled do - = f.check_box :html_emails_enabled - Enable HTML emails - .help-block - By default GitLab sends emails in HTML and plain text formats so mail - clients can choose what format to use. Disable this option if you only - want to send emails in plain text format. - %fieldset - %legend Automatic Git repository housekeeping - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :housekeeping_enabled do - = f.check_box :housekeeping_enabled - Enable automatic repository housekeeping (git repack, git gc) - .help-block - If you keep automatic housekeeping disabled for a long time Git - repository access on your GitLab server will become slower and your - repositories will use more disk space. We recommend to always leave - this enabled. - .checkbox - = f.label :housekeeping_bitmaps_enabled do - = f.check_box :housekeeping_bitmaps_enabled - Enable Git pack file bitmap creation - .help-block - Creating pack file bitmaps makes housekeeping take a little longer but - bitmaps should accelerate 'git clone' performance. - .form-group - = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' - .help-block - Number of Git pushes after which an incremental 'git repack' is run. - .form-group - = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_full_repack_period, class: 'form-control' - .help-block - Number of Git pushes after which a full 'git repack' is run. - .form-group - = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_gc_period, class: 'form-control' - .help-block - Number of Git pushes after which 'git gc' is run. - - %fieldset - %legend Gitaly Timeouts - .form-group - = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_default, class: 'form-control' - .help-block - Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced - for git fetch/push operations or Sidekiq jobs. - .form-group - = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_fast, class: 'form-control' - .help-block - Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. - If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' - can help maintain the stability of the GitLab instance. - .form-group - = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_medium, class: 'form-control' - .help-block - Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. - - %fieldset - %legend Web terminal - .form-group - = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :terminal_max_session_time, class: 'form-control' - .help-block - Maximum time for web terminal websocket connection (in seconds). - 0 for unlimited. - - %fieldset - %legend Real-time features - .form-group - = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :polling_interval_multiplier, class: 'form-control' - .help-block - Change this value to influence how frequently the GitLab UI polls for updates. - If you set the value to 2 all polling intervals are multiplied - by 2, which means that polling happens half as frequently. - The multiplier can also have a decimal value. - The default value (1) is a reasonable choice for the majority of GitLab - installations. Set to 0 to completely disable polling. - = link_to icon('question-circle'), help_page_path('administration/polling') - - %fieldset - %legend Performance optimization - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :authorized_keys_enabled do - = f.check_box :authorized_keys_enabled - Write to "authorized_keys" file - .help-block - By default, we write to the "authorized_keys" file to support Git - over SSH without additional configuration. GitLab can be optimized - to authenticate SSH keys via the database file. Only uncheck this - if you have configured your OpenSSH server to use the - AuthorizedKeysCommand. Click on the help icon for more details. - = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') - - %fieldset - %legend User and IP Rate Limits - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_unauthenticated_enabled do - = f.check_box :throttle_unauthenticated_enabled - Enable unauthenticated request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_authenticated_api_enabled do - = f.check_box :throttle_authenticated_api_enabled - Enable authenticated API request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :throttle_authenticated_web_enabled do - = f.check_box :throttle_authenticated_web_enabled - Enable authenticated web request rate limit - %span.help-block - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group - = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' - .form-group - = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' - - %fieldset - %legend Outbound requests - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :allow_local_requests_from_hooks_and_services do - = f.check_box :allow_local_requests_from_hooks_and_services - Allow requests to the local network from hooks and services - - .form-actions - = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml new file mode 100644 index 00000000000..4acc5b3a0c5 --- /dev/null +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -0,0 +1,27 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_default, class: 'form-control' + .help-block + Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced + for git fetch/push operations or Sidekiq jobs. + .form-group + = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_fast, class: 'form-control' + .help-block + Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. + If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' + can help maintain the stability of the GitLab instance. + .form-group + = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_medium, class: 'form-control' + .help-block + Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml new file mode 100644 index 00000000000..3bc101ddf04 --- /dev/null +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -0,0 +1,22 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :help_page_text, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :help_page_text, class: 'form-control', rows: 4 + .help-block Markdown enabled + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :help_page_hide_commercial_content do + = f.check_box :help_page_hide_commercial_content + Hide marketing-related entries from help + .form-group + = f.label :help_page_support_url, 'Support page URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' + %span.help-block#support_help_block Alternate support URL for help page + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml new file mode 100644 index 00000000000..a173fd38a9c --- /dev/null +++ b/app/views/admin/application_settings/_influx.html.haml @@ -0,0 +1,68 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %p + Setup InfluxDB to measure a wide variety of statistics like the time spent + in running SQL queries. These settings require a + = link_to 'restart', help_page_path('administration/restart_gitlab') + to take effect. + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction') + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :metrics_enabled do + = f.check_box :metrics_enabled + Enable InfluxDB Metrics + .form-group + = f.label :metrics_host, 'InfluxDB host', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com' + .form-group + = f.label :metrics_port, 'InfluxDB port', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :metrics_port, class: 'form-control', placeholder: '8089' + .help-block + The UDP port to use for connecting to InfluxDB. InfluxDB requires that + your server configuration specifies a database to store data in when + sending messages to this port, without it metrics data will not be + saved. + .form-group + = f.label :metrics_pool_size, 'Connection pool size', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :metrics_pool_size, class: 'form-control' + .help-block + The amount of InfluxDB connections to open. Connections are opened + lazily. Users using multi-threaded application servers should ensure + enough connections are available (at minimum the amount of application + server threads). + .form-group + = f.label :metrics_timeout, 'Connection timeout', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :metrics_timeout, class: 'form-control' + .help-block + The amount of seconds after which an InfluxDB connection will time + out. + .form-group + = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :metrics_method_call_threshold, class: 'form-control' + .help-block + A method call is only tracked when it takes longer to complete than + the given amount of milliseconds. + .form-group + = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :metrics_sample_interval, class: 'form-control' + .help-block + The sampling interval in seconds. Sampled data includes memory usage, + retained Ruby objects, file descriptors and so on. + .form-group + = f.label :metrics_packet_size, 'Metrics per packet', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :metrics_packet_size, class: 'form-control' + .help-block + The amount of points to store in a single UDP packet. More points + results in fewer but larger UDP packets being sent. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml new file mode 100644 index 00000000000..b83ffc375d9 --- /dev/null +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -0,0 +1,54 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_unauthenticated_enabled do + = f.check_box :throttle_unauthenticated_enabled + Enable unauthenticated request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_authenticated_api_enabled do + = f.check_box :throttle_authenticated_api_enabled + Enable authenticated API request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :throttle_authenticated_web_enabled do + = f.check_box :throttle_authenticated_web_enabled + Enable authenticated web request rate limit + %span.help-block + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_koding.html.haml b/app/views/admin/application_settings/_koding.html.haml new file mode 100644 index 00000000000..17358cf775b --- /dev/null +++ b/app/views/admin/application_settings/_koding.html.haml @@ -0,0 +1,24 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :koding_enabled do + = f.check_box :koding_enabled + Enable Koding + .help-block + Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. + .form-group + = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .help-block + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml new file mode 100644 index 00000000000..44a11ddc120 --- /dev/null +++ b/app/views/admin/application_settings/_logging.html.haml @@ -0,0 +1,36 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :sentry_enabled do + = f.check_box :sentry_enabled + Enable Sentry + .help-block + %p This setting requires a restart to take effect. + Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: + %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com + + .form-group + = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :sentry_dsn, class: 'form-control' + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :clientside_sentry_dsn, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml new file mode 100644 index 00000000000..d10f609006d --- /dev/null +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -0,0 +1,12 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :allow_local_requests_from_hooks_and_services do + = f.check_box :allow_local_requests_from_hooks_and_services + Allow requests to the local network from hooks and services + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml new file mode 100644 index 00000000000..b28ecf9a039 --- /dev/null +++ b/app/views/admin/application_settings/_pages.html.haml @@ -0,0 +1,22 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :max_pages_size, class: 'form-control' + .help-block 0 for unlimited + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :pages_domain_verification_enabled do + = f.check_box :pages_domain_verification_enabled + Require users to prove ownership of custom domains + .help-block + Domain verification is an essential security measure for public GitLab + sites. Users are required to demonstrate they control a domain before + it is enabled + = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml new file mode 100644 index 00000000000..01d5a31aa9f --- /dev/null +++ b/app/views/admin/application_settings/_performance.html.haml @@ -0,0 +1,19 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :authorized_keys_enabled do + = f.check_box :authorized_keys_enabled + Write to "authorized_keys" file + .help-block + By default, we write to the "authorized_keys" file to support Git + over SSH without additional configuration. GitLab can be optimized + to authenticate SSH keys via the database file. Only uncheck this + if you have configured your OpenSSH server to use the + AuthorizedKeysCommand. Click on the help icon for more details. + = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml new file mode 100644 index 00000000000..5344f030c97 --- /dev/null +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -0,0 +1,16 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :performance_bar_enabled do + = f.check_box :performance_bar_enabled + Enable the Performance Bar + .form-group + = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml new file mode 100644 index 00000000000..56764b3fb81 --- /dev/null +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -0,0 +1,20 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :plantuml_enabled do + = f.check_box :plantuml_enabled + Enable PlantUML + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .help-block + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml new file mode 100644 index 00000000000..48745db2991 --- /dev/null +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -0,0 +1,28 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %p + Enable a Prometheus metrics endpoint at + %code= metrics_path + to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available + = link_to 'here', admin_health_check_path + \. This setting requires a + = link_to 'restart', help_page_path('administration/restart_gitlab') + to take effect. + = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/index') + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :prometheus_metrics_enabled do + = f.check_box :prometheus_metrics_enabled + Enable Prometheus Metrics + - unless Gitlab::Metrics.metrics_folder_present? + .help-block + %strong.cred WARNING: + Environment variable + %code prometheus_multiproc_dir + does not exist or is not pointing to a valid directory. + = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml new file mode 100644 index 00000000000..0a53a75119e --- /dev/null +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -0,0 +1,19 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :polling_interval_multiplier, class: 'form-control' + .help-block + Change this value to influence how frequently the GitLab UI polls for updates. + If you set the value to 2 all polling intervals are multiplied + by 2, which means that polling happens half as frequently. + The multiplier can also have a decimal value. + The default value (1) is a reasonable choice for the majority of GitLab + installations. Set to 0 to completely disable polling. + = link_to icon('question-circle'), help_page_path('administration/polling') + + = f.submit 'Save changes', class: "btn btn-success" + diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml new file mode 100644 index 00000000000..3451ef62458 --- /dev/null +++ b/app/views/admin/application_settings/_registry.html.haml @@ -0,0 +1,10 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :container_registry_token_expire_delay, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml new file mode 100644 index 00000000000..f33769b23c2 --- /dev/null +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -0,0 +1,62 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .sub-section + %h4 Repository checks + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :repository_checks_enabled do + = f.check_box :repository_checks_enabled + Enable Repository Checks + .help-block + GitLab will periodically run + %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck' + in all project and wiki repositories to look for silent disk corruption issues. + .form-group + .col-sm-offset-2.col-sm-10 + = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" + .help-block + If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. + + .sub-section + %h4 Housekeeping + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :housekeeping_enabled do + = f.check_box :housekeeping_enabled + Enable automatic repository housekeeping (git repack, git gc) + .help-block + If you keep automatic housekeeping disabled for a long time Git + repository access on your GitLab server will become slower and your + repositories will use more disk space. We recommend to always leave + this enabled. + .checkbox + = f.label :housekeeping_bitmaps_enabled do + = f.check_box :housekeeping_bitmaps_enabled + Enable Git pack file bitmap creation + .help-block + Creating pack file bitmaps makes housekeeping take a little longer but + bitmaps should accelerate 'git clone' performance. + .form-group + = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' + .help-block + Number of Git pushes after which an incremental 'git repack' is run. + .form-group + = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_full_repack_period, class: 'form-control' + .help-block + Number of Git pushes after which a full 'git repack' is run. + .form-group + = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :housekeeping_gc_period, class: 'form-control' + .help-block + Number of Git pushes after which 'git gc' is run. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml new file mode 100644 index 00000000000..ac31977e1a9 --- /dev/null +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -0,0 +1,58 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .sub-section + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :hashed_storage_enabled do + = f.check_box :hashed_storage_enabled + Create new projects using hashed storage paths + .help-block + Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents + repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. + %em (EXPERIMENTAL) + .form-group + = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' + .col-sm-10 + = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), + {include_hidden: false}, multiple: true, class: 'form-control' + .help-block + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storages") + .sub-section + %h4 Circuit breaker + .form-group + = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_check_interval, class: 'form-control' + .help-block + = circuitbreaker_check_interval_help_text + .form-group + = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_access_retries, class: 'form-control' + .help-block + = circuitbreaker_access_retries_help_text + .form-group + = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' + .help-block + = circuitbreaker_storage_timeout_help_text + .form-group + = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' + .help-block + = circuitbreaker_failure_count_help_text + .form-group + = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' + .help-block + = circuitbreaker_failure_reset_time_help_text + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml new file mode 100644 index 00000000000..864e64b5fa9 --- /dev/null +++ b/app/views/admin/application_settings/_signin.html.haml @@ -0,0 +1,59 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :password_authentication_enabled_for_web do + = f.check_box :password_authentication_enabled_for_web + Password authentication enabled for web interface + .help-block + When disabled, an external authentication provider must be used. + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :password_authentication_enabled_for_git do + = f.check_box :password_authentication_enabled_for_git + Password authentication enabled for Git over HTTP(S) + .help-block + When disabled, a Personal Access Token + - if Gitlab::Auth::LDAP::Config.enabled? + or LDAP password + must be used to authenticate. + - if omniauth_enabled? && button_based_providers.any? + .form-group + = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' + .col-sm-10 + .btn-group{ data: { toggle: 'buttons' } } + - oauth_providers_checkboxes.each do |source| + = source + .form-group + = f.label :two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :require_two_factor_authentication do + = f.check_box :require_two_factor_authentication + Require all users to setup Two-factor authentication + .form-group + = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0' + .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication + .form-group + = f.label :home_page_url, 'Home page URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' + %span.help-block#home_help_block We will redirect non-logged in users to this page + .form-group + = f.label :after_sign_out_path, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' + %span.help-block#after_sign_out_path_help_block We will redirect users to this page after they sign out + .form-group + = f.label :sign_in_text, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :sign_in_text, class: 'form-control', rows: 4 + .help-block Markdown enabled + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml new file mode 100644 index 00000000000..85f311dd894 --- /dev/null +++ b/app/views/admin/application_settings/_signup.html.haml @@ -0,0 +1,58 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :signup_enabled do + = f.check_box :signup_enabled + Sign-up enabled + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :send_user_confirmation_email do + = f.check_box :send_user_confirmation_email + Send confirmation email on sign-up + .form-group + = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + .help-block ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com + .form-group + = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :domain_blacklist_enabled do + = f.check_box :domain_blacklist_enabled + Enable domain blacklist for sign ups + .form-group + .col-sm-offset-2.col-sm-10 + .radio + = label_tag :blacklist_type_file do + = radio_button_tag :blacklist_type, :file + .option-title + Upload blacklist file + .radio + = label_tag :blacklist_type_raw do + = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank? + .option-title + Enter blacklist manually + .form-group.blacklist-file + = f.label :domain_blacklist_file, 'Blacklist file', class: 'control-label col-sm-2' + .col-sm-10 + = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf' + .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries. + .form-group.blacklist-raw + = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com + + .form-group + = f.label :after_sign_up_text, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 + .help-block Markdown enabled + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml new file mode 100644 index 00000000000..25e89097dfe --- /dev/null +++ b/app/views/admin/application_settings/_spam.html.haml @@ -0,0 +1,65 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :recaptcha_enabled do + = f.check_box :recaptcha_enabled + Enable reCAPTCHA + %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts + + .form-group + = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :recaptcha_site_key, class: 'form-control' + .help-block + Generate site and private keys at + %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha + + .form-group + = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :recaptcha_private_key, class: 'form-control' + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :akismet_enabled do + = f.check_box :akismet_enabled + Enable Akismet + %span.help-block#akismet_help_block Helps prevent bots from creating issues + + .form-group + = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :akismet_api_key, class: 'form-control' + .help-block + Generate API key at + %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :unique_ips_limit_enabled do + = f.check_box :unique_ips_limit_enabled + Limit sign in from multiple ips + %span.help-block#unique_ip_help_block + Helps prevent malicious users hide their activity + + .form-group + = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :unique_ips_limit_per_user, class: 'form-control' + .help-block + Maximum number of unique IPs per user + + .form-group + = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :unique_ips_limit_time_window, class: 'form-control' + .help-block + How many seconds an IP will be counted towards the limit + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml new file mode 100644 index 00000000000..36d8838803f --- /dev/null +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -0,0 +1,13 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :terminal_max_session_time, class: 'form-control' + .help-block + Maximum time for web terminal websocket connection (in seconds). + 0 for unlimited. + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml new file mode 100644 index 00000000000..7684e2cfdd1 --- /dev/null +++ b/app/views/admin/application_settings/_usage.html.haml @@ -0,0 +1,37 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :version_check_enabled do + = f.check_box :version_check_enabled + Enable version check + .help-block + GitLab will inform you if a new version is available. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + about what information is shared with GitLab Inc. + .form-group + .col-sm-offset-2.col-sm-10 + - can_be_configured = @application_setting.usage_ping_can_be_configured? + .checkbox + = f.label :usage_ping_enabled do + = f.check_box :usage_ping_enabled, disabled: !can_be_configured + Enable usage ping + .help-block + - if can_be_configured + To help improve GitLab and its user experience, GitLab will + periodically collect usage information. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") + about what information is shared with GitLab Inc. Visit + = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') + to see the JSON payload sent. + - else + The usage ping is disabled, and cannot be configured through this + form. For more information, see the documentation on + = succeed '.' do + = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') + + = f.submit 'Save changes', class: "btn btn-success" + diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml new file mode 100644 index 00000000000..cbc779548f6 --- /dev/null +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -0,0 +1,66 @@ += form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :default_branch_protection, class: 'control-label col-sm-2' + .col-sm-10 + = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' + .form-group.visibility-level-setting + = f.label :default_project_visibility, class: 'control-label col-sm-2' + .col-sm-10 + = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) + .form-group.visibility-level-setting + = f.label :default_snippet_visibility, class: 'control-label col-sm-2' + .col-sm-10 + = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) + .form-group.visibility-level-setting + = f.label :default_group_visibility, class: 'control-label col-sm-2' + .col-sm-10 + = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) + .form-group + = f.label :restricted_visibility_levels, class: 'control-label col-sm-2' + .col-sm-10 + - checkbox_name = 'application_setting[restricted_visibility_levels][]' + = hidden_field_tag(checkbox_name) + - restricted_level_checkboxes('restricted-visibility-help', checkbox_name).each do |level| + .checkbox + = level + %span.help-block#restricted-visibility-help + Selected levels cannot be used by non-admin users for projects or snippets. + If the public level is restricted, user profiles are only visible to logged in users. + .form-group + = f.label :import_sources, class: 'control-label col-sm-2' + .col-sm-10 + - import_sources_checkboxes('import-sources-help').each do |source| + .checkbox= source + %span.help-block#import-sources-help + Enabled sources for code import during project creation. OmniAuth must be configured for GitHub + = link_to "(?)", help_page_path("integration/github") + , Bitbucket + = link_to "(?)", help_page_path("integration/bitbucket") + and GitLab.com + = link_to "(?)", help_page_path("integration/gitlab") + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :project_export_enabled do + = f.check_box :project_export_enabled + Project export enabled + + .form-group + %label.control-label.col-sm-2 Enabled Git access protocols + .col-sm-10 + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.help-block#clone-protocol-help + Allow only the selected protocols to be used for Git access. + + - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| + - field_name = :"#{type}_key_restriction" + .form-group + = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2' + .col-sm-10 + = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index ecc46d86afe..caaa93aa1e2 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -1,5 +1,304 @@ +- breadcrumb_title "Settings" - page_title "Settings" +- @content_class = "limit-container-width" unless fluid_layout +- expanded = Rails.env.test? -%h3.page-title Settings -%hr -= render 'form' +%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Visibility and access controls') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set default and restrict visibility levels. Configure import sources and git access protocol.') + .settings-content + = render 'visibility_and_access' + +%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Account and limit settings') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Session expiration, projects limit and attachment size.') + .settings-content + = render 'account_and_limit' + +%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Sign-up restrictions') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure the way a user creates a new account.') + .settings-content + = render 'signup' + +%section.settings.as-signin.no-animate#js-signin-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Sign-in restrictions') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.') + .settings-content + = render 'signin' + +%section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Help page') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Help page text and support page url.') + .settings-content + = render 'help_page' + +%section.settings.as-pages.no-animate#js-pages-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Pages') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Size and domain settings for static websites') + .settings-content + = render 'pages' + +%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Continuous Integration and Deployment') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Auto DevOps, runners and job artifacts') + .settings-content + = render 'ci_cd' + +%section.settings.as-influx.no-animate#js-influx-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Metrics - Influx') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable and configure InfluxDB metrics.') + .settings-content + = render 'influx' + +%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Metrics - Prometheus') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable and configure Prometheus metrics.') + .settings-content + = render 'prometheus' + +%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Profiling - Performance bar') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable the Performance Bar for a given group.') + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') + .settings-content + = render 'performance_bar' + +%section.settings.as-background.no-animate#js-background-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Background jobs') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure Sidekiq job throttling.') + .settings-content + = render 'background_jobs' + +%section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Spam and Anti-bot Protection') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable reCAPTCHA or Akismet and set IP limits.') + .settings-content + = render 'spam' + +%section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Abuse reports') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set notification email for abuse reports.') + .settings-content + = render 'abuse' + +%section.settings.as-logging.no-animate#js-logging-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Error Reporting and Logging') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable Sentry for error reporting and logging.') + .settings-content + = render 'logging' + +%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Repository storage') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure storage path and circuit breaker settings.') + .settings-content + = render 'repository_storage' + +%section.settings.as-repository-check.no-animate#js-repository-check-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Repository maintenance') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure automatic git checks and housekeeping on repositories.') + .settings-content + = render 'repository_check' + +- if Gitlab.config.registry.enabled + %section.settings.as-registry.no-animate#js-registry-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Container Registry') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various container registry settings.') + .settings-content + = render 'registry' + +- if koding_enabled? + %section.settings.as-koding.no-animate#js-koding-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Koding') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Online IDE integration settings.') + .settings-content + = render 'koding' + +%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('PlantUML') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') + .settings-content + = render 'plantuml' + +%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded) } + .settings-header#usage-statistics + %h4 + = _('Usage statistics') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Enable or disable version check and usage ping.') + .settings-content + = render 'usage' + +%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Email') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various email settings.') + .settings-content + = render 'email' + +%section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Gitaly') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure Gitaly timeouts.') + .settings-content + = render 'gitaly' + +%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Web terminal') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Set max session time for web terminal.') + .settings-content + = render 'terminal' + +%section.settings.as-realtime.no-animate#js-realtime-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Real-time features') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Change this value to influence how frequently the GitLab UI polls for updates.') + .settings-content + = render 'realtime' + +%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Performance optimization') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Various settings that affect GitLab performance.') + .settings-content + = render 'performance' + +%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('User and IP Rate Limits') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Configure limits for web and API requests.') + .settings-content + = render 'ip_limits' + +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Outbound requests') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Allow requests to the local network from hooks and services.') + .settings-content + = render 'outbound' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index c02ddafe108..c47b8a88f56 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -62,12 +62,16 @@ = link_to @project.ssh_url_to_repo, project_path(@project) - if @project.repository.exists? %li - %span.light fs: + %span.light Gitaly storage name: %strong - = @project.repository.path_to_repo + = @project.repository.storage + %li + %span.light Gitaly relative path: + %strong + = @project.repository.relative_path %li - %span.light Storage: + %span.light Storage used: %strong= storage_counter(@project.statistics.storage_size) ( = storage_counter(@project.statistics.repository_size) diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml index 3c0881caa06..22f149d1caa 100644 --- a/app/views/ci/lints/show.html.haml +++ b/app/views/ci/lints/show.html.haml @@ -1,27 +1,9 @@ -- page_title "CI Lint" -- page_description "Validate your GitLab CI configuration file" -- content_for :library_javascripts do - = page_specific_javascript_tag('lib/ace.js') - -%h2 Check your .gitlab-ci.yml - -.ci-linter - .row - = form_tag ci_lint_path, method: :post do - .form-group - .col-sm-12 - .file-holder - .js-file-title.file-title.clearfix - Content of .gitlab-ci.yml - #ci-editor.ci-editor= @content - = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) - .col-sm-12 - .pull-left.prepend-top-10 - = submit_tag('Validate', class: 'btn btn-success submit-yml') - .pull-right.prepend-top-10 - = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml') - - .row.prepend-top-20 - .col-sm-12 - .results.ci-template - = render partial: 'create' if defined?(@status) +.row.empty-state + .col-xs-12 + .svg-content + = image_tag 'illustrations/feature_moved.svg' + .col-xs-12 + .text-content.text-center + %h4= _("GitLab CI Linter has been moved") + %p + = _("To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button.") diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 15201780451..440623b34f5 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -10,7 +10,7 @@ - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" - key_input_name = "#{form_field}[variables_attributes][][key]" -- value_input_name = "#{form_field}[variables_attributes][][value]" +- value_input_name = "#{form_field}[variables_attributes][][secret_value]" - protected_input_name = "#{form_field}[variables_attributes][][protected]" %li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } @@ -43,7 +43,5 @@ %span.toggle-icon = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') - -# EE-specific start - -# EE-specific end %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } = icon('minus-circle') diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index 6693e6f90e8..af518b5b583 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,4 +1,3 @@ Unfortunately, your email message to GitLab could not be processed. - - +\ = @reason diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 7f9486d08d9..8e1dea4afc1 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") - can_create_subgroups = can?(current_user, :create_subgroup, @group) = content_for :meta_tags do diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 54ef51b30e3..c63cf2b31cb 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -22,9 +22,6 @@ = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40 = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' - -# EE-specific start - -# EE-specific end - - unless github_import_configured? %hr %p diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 257f7326409..6513b719199 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html.devise-layout-html +%html.devise-layout-html{ class: system_message_class } = render "layouts/head" %body.ui_indigo.login-page.application.navless{ data: { page: body_data_page } } .page-wrap @@ -16,7 +16,7 @@ %h1 = brand_title = brand_image - - if brand_item&.description? + - if current_appearance&.description? = brand_text - else %h3 Open source software to collaborate on code diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 8718bb3db1a..adf90cb7667 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en" } +%html{ lang: "en", class: system_message_class } = render "layouts/head" %body.ui_indigo.login-page.application.navless = render "layouts/header/empty" diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 059571f795f..5c90d13420f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -80,14 +80,6 @@ = link_to charts_project_graph_path(@project, current_ref) do #{ _('Charts') } - - if project_nav_tab? :container_registry - = nav_link(controller: %w[projects/registry/repositories]) do - = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do - .nav-icon-container - = sprite_icon('disk') - %span.nav-item-name - Registry - - if project_nav_tab? :issues = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do = link_to project_issues_path(@project), class: 'shortcuts-issues' do @@ -231,6 +223,14 @@ %span Charts + - if project_nav_tab? :container_registry + = nav_link(controller: %w[projects/registry/repositories]) do + = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do + .nav-icon-container + = sprite_icon('disk') + %span.nav-item-name + Registry + - if project_nav_tab? :wiki = nav_link(controller: :wikis) do = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml new file mode 100644 index 00000000000..4c507c08ed7 --- /dev/null +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -0,0 +1,26 @@ +%h3 + = @updated_by_user.name + pushed new commits to merge request + = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)) + +- if @existing_commits.any? + - count = @existing_commits.size + %ul + %li + - if count.one? + - commit_id = @existing_commits.first[:short_id] + = link_to(commit_id, project_commit_url(@merge_request.target_project, commit_id)) + - else + = link_to(project_compare_url(@merge_request.target_project, from: @existing_commits.first[:short_id], to: @existing_commits.last[:short_id])) do + #{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]} + = precede ' - ' do + - commits_text = "#{count} commit".pluralize(count) + #{commits_text} from branch `#{@merge_request.target_branch}` + +- if @new_commits.any? + %ul + - @new_commits.each do |commit| + %li + = link_to(commit[:short_id], project_commit_url(@merge_request.target_project, commit[:short_id])) + = precede ' - ' do + #{commit[:title]} diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml new file mode 100644 index 00000000000..553f771f1a6 --- /dev/null +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -0,0 +1,13 @@ +#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference} +\ +#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))} +\ +- if @existing_commits.any? + - count = @existing_commits.size + - commits_id = count.one? ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}" + - commits_text = "#{count} commit".pluralize(count) + + * #{commits_id} - #{commits_text} from branch `#{@merge_request.target_branch}` +\ +- @new_commits.each do |commit| + * #{commit[:short_id]} - #{raw commit[:title]} diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 5dfe973f33c..1e7d9444986 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -7,7 +7,7 @@ .settings-header %h4 Export project - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page. @@ -21,11 +21,11 @@ %li Project uploads %li Project configuration including web hooks and services %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities + %li LFS objects %p The following items will NOT be exported: %ul %li Job traces and artifacts - %li LFS objects %li Container registry images %li CI variables %li Any encrypted tokens diff --git a/app/views/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index 30bf1384b22..30bf1384b22 100644 --- a/app/views/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml new file mode 100644 index 00000000000..6ca8152183d --- /dev/null +++ b/app/views/projects/ci/lints/show.html.haml @@ -0,0 +1,27 @@ +- page_title "CI Lint" +- page_description "Validate your GitLab CI configuration file" +- content_for :library_javascripts do + = page_specific_javascript_tag('lib/ace.js') + +%h2 Check your .gitlab-ci.yml + +.project-ci-linter + .row + = form_tag project_ci_lint_path(@project), method: :post do + .form-group + .col-sm-12 + .file-holder + .js-file-title.file-title.clearfix + Content of .gitlab-ci.yml + #ci-editor.ci-editor= @content + = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) + .col-sm-12 + .pull-left.prepend-top-10 + = submit_tag('Validate', class: 'btn btn-success submit-yml') + .pull-right.prepend-top-10 + = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml') + + .row.prepend-top-20 + .col-sm-12 + .results.project-ci-template + = render partial: 'create' if defined?(@status) diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 2ee0eafcf1a..4c510293204 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -31,7 +31,7 @@ %section.settings#js-cluster-details{ class: ('expanded' if expanded) } .settings-header %h4= s_('ClusterIntegration|Kubernetes cluster details') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content @@ -43,7 +43,7 @@ %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Advanced settings') - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration") .settings-content diff --git a/app/views/projects/clusters/user/_header.html.haml b/app/views/projects/clusters/user/_header.html.haml index 04c7ce96a4b..37f6a788518 100644 --- a/app/views/projects/clusters/user/_header.html.haml +++ b/app/views/projects/clusters/user/_header.html.haml @@ -1,5 +1,5 @@ %h4.prepend-top-20 = s_('ClusterIntegration|Enter the details for your Kubernetes cluster') %p - - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index', anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes').html_safe % { link_to_help_page: link_to_help_page } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 461129a3e0e..74c5317428c 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -49,10 +49,10 @@ .commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title - = markdown(@commit.title, pipeline: :single_line, author: @commit.author) + = markdown_field(@commit, :title) - if @commit.description.present? %pre.commit-description - = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author)) + = preserve(markdown_field(@commit, :description)) .info-well .well-segment.branch-info diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder index 50f7e7a3a33..640b5ecf99e 100644 --- a/app/views/projects/commits/_commit.atom.builder +++ b/app/views/projects/commits/_commit.atom.builder @@ -10,5 +10,5 @@ xml.entry do xml.email commit.author_email end - xml.summary markdown(commit.description, pipeline: :single_line), type: 'html' + xml.summary markdown_field(commit, :description), type: 'html' end diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 75dd4c9ae15..7dd8dc28e5b 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -3,7 +3,7 @@ .settings-header %h4 Deploy Keys - %button.btn.js-settings-toggle.qa-expand-deploy-keys + %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a96485ab155..99eeb9551e3 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -8,7 +8,7 @@ .settings-header %h4 General project settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Update your project name, description, avatar, and other general settings. @@ -64,7 +64,7 @@ .settings-header %h4 Permissions - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Enable or disable certain project features and choose access levels. @@ -79,7 +79,7 @@ .settings-header %h4 Merge request settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Customize your merge request restrictions. @@ -94,7 +94,7 @@ .settings-header %h4 Advanced settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project. diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 8a36fada389..b15fe514a08 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") = render partial: 'flash_messages', locals: { project: @project } diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index e779473c239..ecf186e3dc8 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -35,7 +35,7 @@ = link_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do Download - - if @build.artifacts_metadata? + - if @build.browsable_artifacts? = link_to browse_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default' do Browse diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 849c273db8c..fa27ded7cc2 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -111,4 +111,4 @@ .js-build-options{ data: javascript_build_options } -#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json) } } +#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner') } } diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b423888c875..5ec219fdf00 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -30,6 +30,7 @@ %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', target: '#promote-milestone-modal', milestone_title: @milestone.title, + group_name: @project.group.name, url: promote_project_milestone_path(@milestone.project, @milestone), container: 'body' }, disabled: true, diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 8cdb0a6aff4..b66e0559603 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -18,8 +18,6 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') - -# EE-specific start - -# EE-specific end .md = brand_new_project_guidelines %p @@ -43,8 +41,6 @@ %a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.hidden-xs Import project %span.visible-xs Import - -# EE-specific start - -# EE-specific end .tab-content.gitlab-tab-content .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } @@ -110,10 +106,6 @@ = render "shared/import_form", f: f = render 'new_project_fields', f: f, project_name_id: "import-url-name" - - -# EE-specific start - -# EE-specific end - .save-project-loader.hide .center %h2 diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index ba5845877e5..14d880028c7 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,3 +1,5 @@ +- breadcrumb_title _("Details") + %h2 %i.fa.fa-warning #{ _('No repository') } @@ -10,7 +12,7 @@ .no-repo-actions = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do - #{ _('Create empty bare repository') } + #{ _('Create empty repository') } %strong.prepend-left-10.append-right-10 or @@ -19,4 +21,4 @@ - if can? current_user, :remove_project, @project .prepend-top-20 - = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right" + = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove pull-right" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index 5a397c9d3c7..e49163880c7 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -8,3 +8,5 @@ = render 'form', { f: f } .form-actions = f.submit 'Create New Domain', class: "btn btn-save" + .pull-right + = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 3e6b3346787..c0ee81fe28d 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -10,6 +10,6 @@ "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'), "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s, "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project), - "ci-lint-path" => can?(current_user, :create_pipeline, @project) && ci_lint_path, + "ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project), "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) , "has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } } diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 2a0704bc7af..a09c13176c3 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -2,7 +2,7 @@ - if @protected_branches.empty? .panel-heading %h3.panel-title - Protected branch (#{@protected_branches.size}) + Protected branch (#{@protected_branches_count}) %p.settings-message.text-center There are currently no protected branches, protect a branch with the form above. - else @@ -16,7 +16,7 @@ %col %thead %tr - %th Protected branch (#{@protected_branches.size}) + %th Protected branch (#{@protected_branches_count}) %th Last commit %th Allowed to merge %th Allowed to push diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index e662b877fbb..55d87c35a80 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -4,7 +4,7 @@ .settings-header %h4 Protected Branches - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Keep stable branches secure and force developers to use merge requests. diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index 24baf1cfc89..c33723d8072 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -4,7 +4,7 @@ .settings-header %h4 Protected Tags - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Limit access to creating and updating tags. diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml index 3f42ae58438..02908e16dc5 100644 --- a/app/views/projects/protected_tags/shared/_tags_list.html.haml +++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml @@ -2,7 +2,7 @@ - if @protected_tags.empty? .panel-heading %h3.panel-title - Protected tag (#{@protected_tags.size}) + Protected tag (#{@protected_tags_count}) %p.settings-message.text-center There are currently no protected tags, protect a tag with the form above. - else @@ -17,7 +17,7 @@ %col %thead %tr - %th Protected tag (#{@protected_tags.size}) + %th Protected tag (#{@protected_tags_count}) %th Last commit %th Allowed to create - if can_admin_project diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index 49c90869146..6a681736b6f 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -40,6 +40,12 @@ .col-sm-10 = f.text_field :description, class: 'form-control' .form-group + = label_tag :maximum_timeout_human_readable, class: 'control-label' do + Maximum job timeout + .col-sm-10 + = f.text_field :maximum_timeout_human_readable, class: 'form-control' + .help-block This timeout will take precedence when lower than Project-defined timeout + .form-group = label_tag :tag_list, class: 'control-label' do Tags .col-sm-10 diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index 4e57f5f844d..f33e7e25b68 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -56,6 +56,9 @@ %td Description %td= @runner.description %tr + %td Maximum job timeout + %td= @runner.maximum_timeout_human_readable + %tr %td Last contact %td - if @runner.contacted_at diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml index e8028059487..e8028059487 100644 --- a/app/views/projects/pipelines_settings/_badge.html.haml +++ b/app/views/projects/settings/ci_cd/_badge.html.haml diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 646c01c0989..20868f9ba5d 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -1,6 +1,7 @@ .row.prepend-top-default .col-lg-12 - = form_for @project, url: project_pipelines_settings_path(@project) do |f| + = form_for @project, url: project_settings_ci_cd_path(@project) do |f| + = form_errors(@project) %fieldset.builds-feature .form-group %h5 Auto DevOps (Beta) @@ -73,10 +74,10 @@ %hr .form-group - = f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light' - = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' + = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light' + = f.text_field :build_timeout_human_readable, class: 'form-control' %p.help-block - Per job in minutes. If a job passes this threshold, it will be marked as failed + Per job. If a job passes this threshold, it will be marked as failed = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr @@ -151,10 +152,13 @@ %li excoveralls (Elixir) - %code \[TOTAL\]\s+(\d+\.\d+)% + %li + JaCoCo (Java/Kotlin) + %code Total.*?([0-9]{1,3})% = f.submit 'Save changes', class: "btn btn-save" %hr .row.prepend-top-default - = render partial: 'projects/pipelines_settings/badge', collection: @badges + = render partial: 'badge', collection: @badges diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 756f31f91d9..09268c9943b 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -3,23 +3,24 @@ - page_title "CI / CD" - expanded = Rails.env.test? +- general_expanded = @project.errors.empty? ? expanded : true -%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) } +%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } .settings-header %h4 General pipelines settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Update your CI/CD configuration, like job timeout or Auto DevOps. .settings-content - = render 'projects/pipelines_settings/show' + = render 'form' %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Runners settings - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Register and see your runners for this project. @@ -31,7 +32,7 @@ %h4 = _('Secret variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p.append-bottom-0 = render "ci/variables/content" @@ -42,7 +43,7 @@ .settings-header %h4 Pipeline triggers - %button.btn.js-settings-toggle + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index fa281327eb7..94331a16abd 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- breadcrumb_title "Details" +- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout - show_auto_devops_callout = show_auto_devops_callout?(@project) diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 5eaaa1448d5..3806ead6c87 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -17,6 +17,3 @@ = import_will_timeout_message(ci_cd_only) %li = import_svn_message(ci_cd_only) - --# EE-specific start --# EE-specific end diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 5afbc78df53..56403907844 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -55,6 +55,7 @@ label_title: label.title, label_color: label.color, label_text_color: label.text_color, + group_name: label.project.group.name, target: '#promote-label-modal', container: 'body', toggle: 'modal' } } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index adaddda13eb..975b9cb4729 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -84,7 +84,7 @@ = dropdown_content do .js-due-date-calendar - - if @labels && @labels.any? + - if @labels - selected_labels = issuable.labels .block.labels .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } } @@ -107,7 +107,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@project, :json) if @project) } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path(false) if @project) } } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 5926867e2d7..ac494814f55 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -56,6 +56,7 @@ type: 'button', data: { url: promote_project_milestone_path(milestone.project, milestone), milestone_title: milestone.title, + group_name: @project.group.name, target: '#promote-milestone-modal', container: 'body', toggle: 'modal' } } diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 4539c745f9a..797ff034bb2 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -1,4 +1,6 @@ -- page_title milestone.title, "Milestones" +- page_title milestone.title +- @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title) + - group = local_assigns[:group] - is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone? @@ -17,7 +19,7 @@ Milestone #{milestone.title} - if milestone.due_date || milestone.start_date %span.creator - · + · = milestone_date_range(milestone) - if group .pull-right diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 71c0d740bc8..725bf916592 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -24,21 +24,20 @@ -# DiffNote = f.hidden_field :position - .discussion-form-container - = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do - = render 'projects/zen', f: f, - attr: :note, - classes: 'note-textarea js-note-text', - placeholder: "Write a comment or drag your files here...", - supports_quick_actions: supports_quick_actions, - supports_autocomplete: supports_autocomplete - = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions - .error-alert - - .note-form-actions.clearfix - = render partial: 'shared/notes/comment_button' - - = yield(:note_actions) - - %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } - Discard draft + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do + = render 'projects/zen', f: f, + attr: :note, + classes: 'note-textarea js-note-text', + placeholder: "Write a comment or drag your files here...", + supports_quick_actions: supports_quick_actions, + supports_autocomplete: supports_autocomplete + = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions + .error-alert + + .note-form-actions.clearfix + = render partial: 'shared/notes/comment_button' + + = yield(:note_actions) + + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } + Discard draft diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ad4d39b4aa1..d36ca032558 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -33,6 +33,13 @@ %p.light This URL will be triggered when someone adds a comment %li + = form.check_box :confidential_note_events, class: 'pull-left' + .prepend-left-20 + = form.label :confidential_note_events, class: 'list-label' do + %strong Confidential Comments + %p.light + This URL will be triggered when someone adds a comment on a confidential issue + %li = form.check_box :issues_events, class: 'pull-left' .prepend-left-20 = form.label :issues_events, class: 'list-label' do diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f65e8385ac8..9a11cdb121e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -39,6 +39,10 @@ - github_importer:github_import_stage_import_pull_requests - github_importer:github_import_stage_import_repository +- object_storage_upload +- object_storage:object_storage_background_move +- object_storage:object_storage_migrate_uploads + - pipeline_cache:expire_job_cache - pipeline_cache:expire_pipeline_cache - pipeline_creation:create_pipeline diff --git a/app/workers/concerns/object_storage_queue.rb b/app/workers/concerns/object_storage_queue.rb new file mode 100644 index 00000000000..a80f473a6d4 --- /dev/null +++ b/app/workers/concerns/object_storage_queue.rb @@ -0,0 +1,8 @@ +# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers. +module ObjectStorageQueue + extend ActiveSupport::Concern + + included do + queue_namespace :object_storage + end +end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index 55fb817ca6e..be4203bc7ad 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -28,16 +28,17 @@ class GitGarbageCollectWorker task = task.to_sym cmd = command(task) - repo_path = project.repository.path_to_repo - description = "'#{cmd.join(' ')}' in #{repo_path}" - - Gitlab::GitLogger.info(description) gitaly_migrate(GITALY_MIGRATED_TASKS[task]) do |is_enabled| if is_enabled gitaly_call(task, project.repository.raw_repository) else + repo_path = project.repository.path_to_repo + description = "'#{cmd.join(' ')}' in #{repo_path}" + Gitlab::GitLogger.info(description) + output, status = Gitlab::Popen.popen(cmd, repo_path) + Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero? end end diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 67c54fbf10e..b925741934a 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -5,7 +5,7 @@ class NewNoteWorker # old `NewNoteWorker` jobs (can remove later) def perform(note_id, _params = {}) if note = Note.find_by(id: note_id) - NotificationService.new.new_note(note) + NotificationService.new.new_note(note) if note.can_create_notification? Notes::PostProcessService.new(note).execute else Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb new file mode 100644 index 00000000000..9c4d72e0ecf --- /dev/null +++ b/app/workers/object_storage/background_move_worker.rb @@ -0,0 +1,29 @@ +module ObjectStorage + class BackgroundMoveWorker + include ApplicationWorker + include ObjectStorageQueue + + sidekiq_options retry: 5 + + def perform(uploader_class_name, subject_class_name, file_field, subject_id) + uploader_class = uploader_class_name.constantize + subject_class = subject_class_name.constantize + + return unless uploader_class < ObjectStorage::Concern + return unless uploader_class.object_store_enabled? + return unless uploader_class.background_upload_enabled? + + subject = subject_class.find(subject_id) + uploader = build_uploader(subject, file_field&.to_sym) + uploader.migrate!(ObjectStorage::Store::REMOTE) + end + + def build_uploader(subject, mount_point) + case subject + when Upload then subject.build_uploader(mount_point) + else + subject.send(mount_point) # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb new file mode 100644 index 00000000000..a6b2c251254 --- /dev/null +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true +# rubocop:disable Metrics/LineLength +# rubocop:disable Style/Documentation + +module ObjectStorage + class MigrateUploadsWorker + include ApplicationWorker + include ObjectStorageQueue + + SanityCheckError = Class.new(StandardError) + + class Upload < ActiveRecord::Base + # Upper limit for foreground checksum processing + CHECKSUM_THRESHOLD = 100.megabytes + + belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + + validates :size, presence: true + validates :path, presence: true + validates :model, presence: true + validates :uploader, presence: true + + before_save :calculate_checksum!, if: :foreground_checksummable? + after_commit :schedule_checksum, if: :checksummable? + + scope :stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) } + scope :stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) } + + def self.hexdigest(path) + Digest::SHA256.file(path).hexdigest + end + + def absolute_path + raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? + return path unless relative_path? + + uploader_class.absolute_path(self) + end + + def calculate_checksum! + self.checksum = nil + return unless checksummable? + + self.checksum = self.class.hexdigest(absolute_path) + end + + def build_uploader(mounted_as = nil) + uploader_class.new(model, mounted_as).tap do |uploader| + uploader.upload = self + uploader.retrieve_from_store!(identifier) + end + end + + def exist? + File.exist?(absolute_path) + end + + def local? + return true if store.nil? + + store == ObjectStorage::Store::LOCAL + end + + private + + def checksummable? + checksum.nil? && local? && exist? + end + + def foreground_checksummable? + checksummable? && size <= CHECKSUM_THRESHOLD + end + + def schedule_checksum + UploadChecksumWorker.perform_async(id) + end + + def relative_path? + !path.start_with?('/') + end + + def identifier + File.basename(path) + end + + def uploader_class + Object.const_get(uploader) + end + end + + class MigrationResult + attr_reader :upload + attr_accessor :error + + def initialize(upload, error = nil) + @upload, @error = upload, error + end + + def success? + error.nil? + end + + def to_s + success? ? "Migration successful." : "Error while migrating #{upload.id}: #{error.message}" + end + end + + module Report + class MigrationFailures < StandardError + attr_reader :errors + + def initialize(errors) + @errors = errors + end + + def message + errors.map(&:message).join("\n") + end + end + + def report!(results) + success, failures = results.partition(&:success?) + + Rails.logger.info header(success, failures) + Rails.logger.warn failures(failures) + + raise MigrationFailures.new(failures.map(&:error)) if failures.any? + end + + def header(success, failures) + "Migrated #{success.count}/#{success.count + failures.count} files." + end + + def failures(failures) + failures.map { |f| "\t#{f}" }.join('\n') + end + end + + include Report + + def self.enqueue!(uploads, model_class, mounted_as, to_store) + sanity_check!(uploads, model_class, mounted_as) + + perform_async(uploads.ids, model_class.to_s, mounted_as, to_store) + end + + # We need to be sure all the uploads are for the same uploader and model type + # and that the mount point exists if provided. + # + def self.sanity_check!(uploads, model_class, mounted_as) + upload = uploads.first + uploader_class = upload.uploader.constantize + uploader_types = uploads.map(&:uploader).uniq + model_types = uploads.map(&:model_type).uniq + model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class + + raise(SanityCheckError, "Multiple uploaders found: #{uploader_types}") unless uploader_types.count == 1 + raise(SanityCheckError, "Multiple model types found: #{model_types}") unless model_types.count == 1 + raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount + end + + def perform(*args) + args_check!(args) + + (ids, model_type, mounted_as, to_store) = args + + @model_class = model_type.constantize + @mounted_as = mounted_as&.to_sym + @to_store = to_store + + uploads = Upload.preload(:model).where(id: ids) + + sanity_check!(uploads) + results = migrate(uploads) + + report!(results) + rescue SanityCheckError => e + # do not retry: the job is insane + Rails.logger.warn "#{self.class}: Sanity check error (#{e.message})" + end + + def sanity_check!(uploads) + self.class.sanity_check!(uploads, @model_class, @mounted_as) + end + + def args_check!(args) + return if args.count == 4 + + case args.count + when 3 then raise SanityCheckError, "Job is missing the `model_type` argument." + else + raise SanityCheckError, "Job has wrong arguments format." + end + end + + def build_uploaders(uploads) + uploads.map { |upload| upload.build_uploader(@mounted_as) } + end + + def migrate(uploads) + build_uploaders(uploads).map(&method(:process_uploader)) + end + + def process_uploader(uploader) + MigrationResult.new(uploader.upload).tap do |result| + begin + uploader.migrate!(@to_store) + rescue => e + result.error = e + end + end + end + end +end diff --git a/app/workers/object_storage_upload_worker.rb b/app/workers/object_storage_upload_worker.rb new file mode 100644 index 00000000000..5c80f34069c --- /dev/null +++ b/app/workers/object_storage_upload_worker.rb @@ -0,0 +1,21 @@ +# @Deprecated - remove once the `object_storage_upload` queue is empty +# The queue has been renamed `object_storage:object_storage_background_upload` +# +class ObjectStorageUploadWorker + include ApplicationWorker + + sidekiq_options retry: 5 + + def perform(uploader_class_name, subject_class_name, file_field, subject_id) + uploader_class = uploader_class_name.constantize + subject_class = subject_class_name.constantize + + return unless uploader_class < ObjectStorage::Concern + return unless uploader_class.object_store_enabled? + return unless uploader_class.background_upload_enabled? + + subject = subject_class.find(subject_id) + uploader = subject.public_send(file_field) # rubocop:disable GitlabSecurity/PublicSend + uploader.migrate!(ObjectStorage::Store::REMOTE) + end +end diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index 0b502143e5d..c3d84bb0b93 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -4,11 +4,19 @@ class ProjectExportWorker sidekiq_options retry: 3 - def perform(current_user_id, project_id, params = {}) - params = params.with_indifferent_access + def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) project = Project.find(project_id) + after_export = build!(after_export_strategy) - ::Projects::ImportExport::ExportService.new(project, current_user, params).execute + ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export) + end + + private + + def build!(after_export_strategy) + strategy_klass = after_export_strategy&.delete('klass') + + Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy) end end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 07584fab7c8..51fad4faf36 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -4,24 +4,47 @@ class RepositoryForkWorker include ProjectStartImport include ProjectImportOptions - def perform(project_id, forked_from_repository_storage_path, source_disk_path) - project = Project.find(project_id) + def perform(*args) + target_project_id = args.shift + target_project = Project.find(target_project_id) - return unless start_fork(project) + # By v10.8, we should've drained the queue of all jobs using the old arguments. + # We can remove the else clause if we're no longer logging the message in that clause. + # See https://gitlab.com/gitlab-org/gitaly/issues/1110 + if args.empty? + source_project = target_project.forked_from_project + return target_project.mark_import_as_failed('Source project cannot be found.') unless source_project - Gitlab::Metrics.add_event(:fork_repository, - source_path: source_disk_path, - target_path: project.disk_path) + fork_repository(target_project, source_project.repository_storage, source_project.disk_path) + else + Rails.logger.info("Project #{target_project.id} is being forked using old-style arguments.") + + source_repository_storage_path, source_disk_path = *args - result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path, - project.repository_storage_path, project.disk_path) - raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result + source_repository_storage_name = Gitlab.config.repositories.storages.find do |_, info| + info.legacy_disk_path == source_repository_storage_path + end&.first || raise("no shard found for path '#{source_repository_storage_path}'") - project.after_import + fork_repository(target_project, source_repository_storage_name, source_disk_path) + end end private + def fork_repository(target_project, source_repository_storage_name, source_disk_path) + return unless start_fork(target_project) + + Gitlab::Metrics.add_event(:fork_repository, + source_path: source_disk_path, + target_path: target_project.disk_path) + + result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path, + target_project.repository_storage, target_project.disk_path) + raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result + + target_project.after_import + end + def start_fork(project) return true if start(project) |