diff options
author | jhampton <jhampton@gitlab.com> | 2018-12-07 21:21:43 +0300 |
---|---|---|
committer | jhampton <jhampton@gitlab.com> | 2018-12-07 21:21:43 +0300 |
commit | 6de31cddb81613045ae4ac920a054c53f2028949 (patch) | |
tree | 5da9d29ba985e9ce2b81f02c33fd43b222e91e10 /app | |
parent | 02ef0523634123f3abc3dd6235ff229e38f40341 (diff) | |
parent | 88c0984d077e2a85d684d71d036d27278cd81182 (diff) |
Merge remote-tracking branch 'origin/master' into 20422-hide-ui-variables-by-default
Diffstat (limited to 'app')
403 files changed, 4683 insertions, 2448 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 3f7a1ef1bfc..de003e70e61 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,15 +5,17 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', - mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', + projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', + projectRunnersPath: '/api/:version/projects/:id/runners', mergeRequestsPath: '/api/:version/merge_requests', - mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', - mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', @@ -99,36 +101,45 @@ const Api = { }, // Return Merge Request for project - mergeRequest(projectPath, mergeRequestId, params = {}) { - const url = Api.buildUrl(Api.mergeRequestPath) + projectMergeRequest(projectPath, mergeRequestId, params = {}) { + const url = Api.buildUrl(Api.projectMergeRequestPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url, { params }); }, - mergeRequests(params = {}) { - const url = Api.buildUrl(Api.mergeRequestsPath); - - return axios.get(url, { params }); - }, - - mergeRequestChanges(projectPath, mergeRequestId) { - const url = Api.buildUrl(Api.mergeRequestChangesPath) + projectMergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.projectMergeRequestChangesPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url); }, - mergeRequestVersions(projectPath, mergeRequestId) { - const url = Api.buildUrl(Api.mergeRequestVersionsPath) + projectMergeRequestVersions(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.projectMergeRequestVersionsPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); return axios.get(url); }, + projectRunners(projectPath, config = {}) { + const url = Api.buildUrl(Api.projectRunnersPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url, config); + }, + + mergeRequests(params = {}) { + const url = Api.buildUrl(Api.mergeRequestsPath); + + return axios.get(url, { params }); + }, + newLabel(namespacePath, projectPath, data, callback) { let url; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 720f30e18e6..35380ca49fb 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -26,6 +26,9 @@ export default function renderMermaid($els) { }, // mermaidAPI options theme: 'neutral', + flowchart: { + htmlLabels: false, + }, }); $els.each((i, el) => { diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index c09d9ccddd6..d8056e48d4e 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -50,10 +50,11 @@ function hideOrShowHelpBlock(form) { } $(() => { - const $form = $('form.js-requires-input'); - if ($form) { + $('form.js-requires-input').each((i, el) => { + const $form = $(el); + $form.requiresInput(); hideOrShowHelpBlock($form); $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); - } + }); }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 31651658fe6..d899b7fbd8c 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -92,20 +92,7 @@ export default { {{ selectedProjectName }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title"> - <span>Projects</span> - <button - aria-label="Close" - type="button" - class="dropdown-title-button dropdown-menu-close" - > - <icon - name="merge-request-close-m" - data-hidden="true" - class="dropdown-menu-close-icon" - /> - </button> - </div> + <div class="dropdown-title">Projects</div> <div class="dropdown-input"> <input class="dropdown-input-field" type="search" placeholder="Search projects" /> <icon name="search" class="dropdown-input-search" data-hidden="true" /> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index aff32d95db1..cf70a48f076 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import PersistentUserCallout from '../persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; @@ -67,7 +67,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); - Clusters.initDismissableCallout(); + initDismissableCallout('.js-cluster-security-warning'); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(clusterType); @@ -108,12 +108,6 @@ export default class Clusters { }); } - static initDismissableCallout() { - const callout = document.querySelector('.js-cluster-security-warning'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - } - addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index a37cb4def28..665a9c77822 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -296,7 +296,6 @@ export default { :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://cert-manager.readthedocs.io/en/latest/#" > <div slot="description" v-html="certManagerDescription"></div> @@ -396,6 +395,7 @@ export default { </div> </application-row> <application-row + v-if="isProjectCluster" id="knative" :logo-url="knativeLogo" :title="applications.knative.title" @@ -405,17 +405,15 @@ export default { :request-reason="applications.knative.requestReason" :install-application-request-params="{ hostname: applications.knative.hostname }" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://github.com/knative/docs" > <div slot="description"> <p> {{ - s__(`ClusterIntegration|Knative (pronounced kay-nay-tiv) extends - Kubernetes to provide a set of middleware components that are - essential to build modern, source-centric, and container-based - applications that can run anywhere: on premises, in the cloud, or - even in a third-party data center.`) + s__(`ClusterIntegration|Knative extends Kubernetes to provide + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -433,7 +431,7 @@ export default { /> </div> </template> - <template v-else> + <template v-else-if="helmInstalled"> <div class="form-group"> <label for="knative-domainname"> {{ s__('ClusterIntegration|Knative Domain Name:') }} diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 22da38ce7a5..bf9244df7f7 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -102,6 +102,12 @@ export default { if (this.shouldShow) { this.fetchData(); } + + const id = window && window.location && window.location.hash; + + if (id) { + this.setHighlightedRow(id.slice(1)); + } }, created() { this.adjustView(); @@ -114,6 +120,7 @@ export default { 'fetchDiffFiles', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', + 'setHighlightedRow', ]), fetchData() { this.fetchDiffFiles() diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index e405d8b20ae..11cc4c09fed 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -90,6 +90,8 @@ export default { :old-sha="diffFile.diff_refs.base_sha" :file-hash="diffFile.file_hash" :project-path="projectPath" + :a-mode="diffFile.a_mode" + :b-mode="diffFile.b_mode" > <image-diff-overlay slot="image-overlay" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f7e3655ea40..3b2a0d156ca 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -52,7 +52,9 @@ export default { (!this.file.highlighted_diff_lines && !this.isLoadingCollapsedDiff && !this.file.too_large && - this.file.text) + this.file.text && + !this.file.renamed_file && + !this.file.mode_changed) ); }, showLoadingIcon() { @@ -143,9 +145,8 @@ export default { <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + >Fork</a > - Fork - </a> <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @@ -163,9 +164,9 @@ export default { <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle"> - {{ __('Click to expand it.') }} - </a> + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> </div> <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff"> {{ __('This source diff could not be displayed because it is too large.') }} diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index b969017a2bb..0c0a0faa59d 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -56,9 +56,12 @@ export default { return `${noteData.author.name}: ${note}`; }, toggleDiscussions() { + const forceExpanded = this.discussions.some(discussion => !discussion.expanded); + this.discussions.forEach(discussion => { this.toggleDiscussion({ discussionId: discussion.id, + forceExpanded, }); }); }, diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index c02561b7599..c0613d80d37 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -72,6 +72,13 @@ export default { diffFiles: state => state.diffs.diffFiles, }), ...mapGetters(['isLoggedIn']), + lineCode() { + return ( + this.line.line_code || + (this.line.left && this.line.line.left.line_code) || + (this.line.right && this.line.right.line_code) + ); + }, lineHref() { return `#${this.line.line_code || ''}`; }, @@ -97,9 +104,9 @@ export default { }, }, methods: { - ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), + ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']), handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code }); + this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); }, handleLoadMoreLines() { if (this.isRequesting) { @@ -160,7 +167,7 @@ export default { > <template v-else> <button - v-if="shouldShowCommentButton" + v-show="shouldShowCommentButton" type="button" class="add-diff-note js-add-diff-note-button qa-diff-comment" title="Add a comment to this line" @@ -168,7 +175,13 @@ export default { > <icon :size="12" name="comment" /> </button> - <a v-if="lineNumber" :data-linenumber="lineNumber" :href="lineHref"> </a> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode);" + > + </a> <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> </template> </div> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index c7cef74fe40..9fd02acbd6e 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -73,6 +73,7 @@ export default { this.cancelCommentForm({ lineCode: this.line.line_code, + fileHash: this.diffFileHash, }); this.$nextTick(() => { this.resetAutoSave(); diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index f4eb956adcb..d174b13e133 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; import DiffLineGutterContent from './diff_line_gutter_content.vue'; import { MATCH_LINE_TYPE, @@ -30,6 +30,11 @@ export default { type: String, required: true, }, + isHighlighted: { + type: Boolean, + required: true, + default: false, + }, diffViewType: { type: String, required: false, @@ -85,6 +90,7 @@ export default { const { type } = this.line; return { + hll: this.isHighlighted, [type]: type, [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_HOVER_CLASS_NAME]: @@ -99,6 +105,7 @@ export default { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, }, + methods: mapActions('diffs', ['setHighlightedRow']), }; </script> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 91b87fb042c..aa40b24950a 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -1,5 +1,4 @@ <script> -import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -17,29 +16,31 @@ export default { type: String, required: true, }, - lineIndex: { - type: Number, - required: true, - }, }, computed: { - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), className() { return this.line.discussions.length ? '' : 'js-temp-notes-holder'; }, + shouldRender() { + if (this.line.hasForm) return true; + + if (!this.line.discussions || !this.line.discussions.length) { + return false; + } + + return this.line.discussions.every(discussion => discussion.expanded); + }, }, }; </script> <template> - <tr :class="className" class="notes_holder"> + <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content" colspan="3"> <div class="content"> <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> <diff-line-note-form - v-if="diffLineCommentForms[line.line_code]" + v-if="line.hasForm" :diff-file-hash="diffFileHash" :line="line" :note-target-line="line" diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 8d53fbded73..c764cbeb8e0 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters, mapActions } from 'vuex'; +import { mapGetters, mapActions, mapState } from 'vuex'; import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, @@ -40,6 +40,11 @@ export default { }; }, computed: { + ...mapState({ + isHighlighted(state) { + return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; + }, + }), ...mapGetters('diffs', ['isInlineView']), isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; @@ -91,6 +96,7 @@ export default { :is-bottom="isBottom" :is-hover="isHover" :show-comment-button="true" + :is-highlighted="isHighlighted" class="diff-line-num old_line" /> <diff-table-cell @@ -100,8 +106,18 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isHover" + :is-highlighted="isHighlighted" class="diff-line-num new_line qa-new-diff-line" /> - <td :class="line.type" class="line_content" v-html="line.rich_text"></td> + <td + :class="[ + line.type, + { + hll: isHighlighted, + }, + ]" + class="line_content" + v-html="line.rich_text" + ></td> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index fafc1649ce7..6a0ce760e6d 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters, mapState } from 'vuex'; +import { mapGetters } from 'vuex'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; @@ -19,23 +19,18 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId', 'shouldRenderInlineCommentRow']), - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), + ...mapGetters('diffs', ['commitId']), diffLinesLength() { return this.diffLines.length; }, - userColorScheme() { - return window.gon.user_color_scheme; - }, }, + userColorScheme: window.gon.user_color_scheme, }; </script> <template> <table - :class="userColorScheme" + :class="$options.userColorScheme" :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" > @@ -49,11 +44,9 @@ export default { :is-bottom="index + 1 === diffLinesLength" /> <inline-diff-comment-row - v-if="shouldRenderInlineCommentRow(line)" - :key="index" + :key="`icr-${index}`" :diff-file-hash="diffFile.file_hash" :line="line" - :line-index="index" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index c6b50983277..b98463d3dd3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -1,5 +1,4 @@ <script> -import { mapState } from 'vuex'; import diffDiscussions from './diff_discussions.vue'; import diffLineNoteForm from './diff_line_note_form.vue'; @@ -23,22 +22,13 @@ export default { }, }, computed: { - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), - leftLineCode() { - return this.line.left && this.line.left.line_code; - }, - rightLineCode() { - return this.line.right && this.line.right.line_code; - }, hasExpandedDiscussionOnLeft() { - return this.line.left && this.line.left.discussions + return this.line.left && this.line.left.discussions.length ? this.line.left.discussions.every(discussion => discussion.expanded) : false; }, hasExpandedDiscussionOnRight() { - return this.line.right && this.line.right.discussions + return this.line.right && this.line.right.discussions.length ? this.line.right.discussions.every(discussion => discussion.expanded) : false; }, @@ -57,9 +47,10 @@ export default { ); }, showRightSideCommentForm() { - return ( - this.line.right && this.line.right.type && this.diffLineCommentForms[this.rightLineCode] - ); + return this.line.right && this.line.right.type && this.line.right.hasForm; + }, + showLeftSideCommentForm() { + return this.line.left && this.line.left.hasForm; }, className() { return (this.left && this.line.left.discussions.length > 0) || @@ -67,12 +58,30 @@ export default { ? '' : 'js-temp-notes-holder'; }, + shouldRender() { + const { line } = this; + const hasDiscussion = + (line.left && line.left.discussions && line.left.discussions.length) || + (line.right && line.right.discussions && line.right.discussions.length); + + if ( + hasDiscussion && + (this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight) + ) { + return true; + } + + const hasCommentFormOnLeft = line.left && line.left.hasForm; + const hasCommentFormOnRight = line.right && line.right.hasForm; + + return hasCommentFormOnLeft || hasCommentFormOnRight; + }, }, }; </script> <template> - <tr :class="className" class="notes_holder"> + <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content parallel old" colspan="2"> <div v-if="shouldRenderDiscussionsOnLeft" class="content"> <diff-discussions @@ -81,7 +90,7 @@ export default { /> </div> <diff-line-note-form - v-if="diffLineCommentForms[leftLineCode]" + v-if="showLeftSideCommentForm" :diff-file-hash="diffFileHash" :line="line.left" :note-target-line="line.left" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 248dfd9815e..caf0df8a4e3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -1,5 +1,5 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import $ from 'jquery'; import DiffTableCell from './diff_table_cell.vue'; import { @@ -43,6 +43,15 @@ export default { }; }, computed: { + ...mapState({ + isHighlighted(state) { + const lineCode = + (this.line.left && this.line.left.line_code) || + (this.line.right && this.line.right.line_code); + + return lineCode ? lineCode === state.diffs.highlightedRow : false; + }, + }), isContextLine() { return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; }, @@ -57,7 +66,14 @@ export default { return OLD_NO_NEW_LINE_TYPE; } - return this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; + const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; + + return [ + lineTypeClass, + { + hll: this.isHighlighted, + }, + ]; }, }, created() { @@ -114,6 +130,7 @@ export default { :line-type="oldLineType" :is-bottom="isBottom" :is-hover="isLeftHover" + :is-highlighted="isHighlighted" :show-comment-button="true" :diff-view-type="parallelDiffViewType" line-position="left" @@ -139,6 +156,7 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isRightHover" + :is-highlighted="isHighlighted" :show-comment-button="true" :diff-view-type="parallelDiffViewType" line-position="right" @@ -146,7 +164,12 @@ export default { /> <td :id="line.right.line_code" - :class="line.right.type" + :class="[ + line.right.type, + { + hll: isHighlighted, + }, + ]" class="line_content parallel right-side" @mousedown.native="handleParallelLineMouseDown" v-html="line.right.rich_text" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 771b8a80352..9a6e0e82529 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; @@ -19,23 +19,18 @@ export default { }, }, computed: { - ...mapGetters('diffs', ['commitId', 'shouldRenderParallelCommentRow']), - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), + ...mapGetters('diffs', ['commitId']), diffLinesLength() { return this.diffLines.length; }, - userColorScheme() { - return window.gon.user_color_scheme; - }, }, + userColorScheme: window.gon.user_color_scheme, }; </script> <template> <div - :class="userColorScheme" + :class="$options.userColorScheme" :data-commit-id="commitId" class="code diff-wrap-lines js-syntax-highlight text-file" > @@ -50,7 +45,6 @@ export default { :is-bottom="index + 1 === diffLinesLength" /> <parallel-diff-comment-row - v-if="shouldRenderParallelCommentRow(line)" :key="`dcr-${index}`" :line="line" :diff-file-hash="diffFile.file_hash" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index a3de058b20e..952963e0711 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -33,6 +33,10 @@ export const fetchDiffFiles = ({ state, commit }) => { .then(handleLocationHash); }; +export const setHighlightedRow = ({ commit }, lineCode) => { + commit(types.SET_HIGHLIGHTED_ROW, lineCode); +}; + // This is adding line discussions to the actual lines in the diff tree // once for parallel and once for inline mode export const assignDiscussionsToDiff = ( @@ -99,12 +103,12 @@ export const setParallelDiffViewType = ({ commit }) => { historyPushState(url); }; -export const showCommentForm = ({ commit }, params) => { - commit(types.ADD_COMMENT_FORM_LINE, params); +export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { + commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true }); }; -export const cancelCommentForm = ({ commit }, params) => { - commit(types.REMOVE_COMMENT_FORM_LINE, params); +export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => { + commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: false }); }; export const loadMoreLines = ({ commit }, options) => { @@ -127,7 +131,7 @@ export const loadMoreLines = ({ commit }, options) => { export const scrollToLineIfNeededInline = (_, line) => { const hash = getLocationHash(); - if (hash && line.lineCode === hash) { + if (hash && line.line_code === hash) { handleLocationHash(); } }; @@ -137,19 +141,25 @@ export const scrollToLineIfNeededParallel = (_, line) => { if ( hash && - ((line.left && line.left.lineCode === hash) || (line.right && line.right.lineCode === hash)) + ((line.left && line.left.line_code === hash) || (line.right && line.right.line_code === hash)) ) { handleLocationHash(); } }; -export const loadCollapsedDiff = ({ commit }, file) => - axios.get(file.loadCollapsedDiffUrl).then(res => { - commit(types.ADD_COLLAPSED_DIFFS, { - file, - data: res.data, +export const loadCollapsedDiff = ({ commit, getters }, file) => + axios + .get(file.load_collapsed_diff_url, { + params: { + commit_id: getters.commitId, + }, + }) + .then(res => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, + }); }); - }); export const expandAllFiles = ({ commit }) => { commit(types.EXPAND_ALL_FILES); @@ -182,8 +192,9 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { }); }; -export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { +export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ + commit: state.commit, note, ...formData, }); @@ -191,8 +202,8 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { return dispatch('saveNote', postData, { root: true }) .then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) + .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) - .then(() => dispatch('startTaskList', null, { root: true })) .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 6a87b712b48..fdf1efbb10e 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -70,40 +70,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash, ) || []; -export const shouldRenderParallelCommentRow = state => line => { - const hasDiscussion = - (line.left && line.left.discussions && line.left.discussions.length) || - (line.right && line.right.discussions && line.right.discussions.length); - - const hasExpandedDiscussionOnLeft = - line.left && line.left.discussions && line.left.discussions.length - ? line.left.discussions.every(discussion => discussion.expanded) - : false; - const hasExpandedDiscussionOnRight = - line.right && line.right.discussions && line.right.discussions.length - ? line.right.discussions.every(discussion => discussion.expanded) - : false; - - if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { - return true; - } - - const hasCommentFormOnLeft = line.left && state.diffLineCommentForms[line.left.line_code]; - const hasCommentFormOnRight = line.right && state.diffLineCommentForms[line.right.line_code]; - - return hasCommentFormOnLeft || hasCommentFormOnRight; -}; - -export const shouldRenderInlineCommentRow = state => line => { - if (state.diffLineCommentForms[line.line_code]) return true; - - if (!line.discussions || line.discussions.length === 0) { - return false; - } - - return line.discussions.every(discussion => discussion.expanded); -}; - // prevent babel-plugin-rewire from generating an invalid default during karma∂ tests export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 2f59a3822f4..98e57d52d77 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -18,7 +18,6 @@ export default () => ({ diffFiles: [], mergeRequestDiffs: [], mergeRequestDiff: null, - diffLineCommentForms: {}, diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, tree: [], treeEntries: {}, @@ -27,4 +26,5 @@ export default () => ({ currentDiffFileId: '', projectPath: '', commentForms: [], + highlightedRow: null, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index e011031e72c..0338cde3658 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -3,8 +3,7 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; -export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE'; -export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE'; +export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM'; export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; @@ -18,3 +17,4 @@ export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; +export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 2133cfe4825..331fb052371 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { sortTree } from '~/ide/stores/utils'; import { @@ -49,12 +48,30 @@ export default { Object.assign(state, { diffViewType }); }, - [types.ADD_COMMENT_FORM_LINE](state, { lineCode }) { - Vue.set(state.diffLineCommentForms, lineCode, true); - }, + [types.TOGGLE_LINE_HAS_FORM](state, { lineCode, fileHash, hasForm }) { + const diffFile = state.diffFiles.find(f => f.file_hash === fileHash); + + if (!diffFile) return; + + if (diffFile.highlighted_diff_lines) { + diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; + } + + if (diffFile.parallel_diff_lines) { + const line = diffFile.parallel_diff_lines.find(l => { + const { left, right } = l; + + return (left && left.line_code === lineCode) || (right && right.line_code === lineCode); + }); + + if (line.left && line.left.line_code === lineCode) { + line.left.hasForm = hasForm; + } - [types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) { - Vue.delete(state.diffLineCommentForms, lineCode); + if (line.right && line.right.line_code === lineCode) { + line.right.hasForm = hasForm; + } + } }, [types.ADD_CONTEXT_LINES](state, options) { @@ -68,6 +85,7 @@ export default { ...line, line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`, discussions: line.discussions || [], + hasForm: false, })); addContextLines({ @@ -112,7 +130,7 @@ export default { if (file.highlighted_diff_lines) { file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => { - if (lineCheck(line)) { + if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) { return { ...line, discussions: line.discussions.concat(discussion), @@ -132,11 +150,17 @@ export default { return { left: { ...line.left, - discussions: left ? line.left.discussions.concat(discussion) : [], + discussions: + left && !line.left.discussions.some(({ id }) => id === discussion.id) + ? line.left.discussions.concat(discussion) + : (line.left && line.left.discussions) || [], }, right: { ...line.right, - discussions: right && !left ? line.right.discussions.concat(discussion) : [], + discussions: + right && !left && !line.right.discussions.some(({ id }) => id === discussion.id) + ? line.right.discussions.concat(discussion) + : (line.right && line.right.discussions) || [], }, }; } @@ -223,4 +247,7 @@ export default { [types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) { state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash); }, + [types.SET_HIGHLIGHTED_ROW](state, lineCode) { + state.highlightedRow = lineCode; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d9d3c0f2ca2..cbaa0e26395 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -27,6 +27,7 @@ export const getReversePosition = linePosition => { export function getFormData(params) { const { + commit, note, noteableType, noteableData, @@ -66,7 +67,7 @@ export function getFormData(params) { position, noteable_type: noteableType, noteable_id: noteableData.id, - commit_id: '', + commit_id: commit && commit.id, type: diffFile.diff_refs.start_sha && diffFile.diff_refs.head_sha ? DIFF_NOTE_TYPE @@ -209,9 +210,11 @@ export function prepareDiffData(diffData) { const line = file.parallel_diff_lines[u]; if (line.left) { line.left = trimFirstCharOfLineContent(line.left); + line.left.hasForm = false; } if (line.right) { line.right = trimFirstCharOfLineContent(line.right); + line.right.hasForm = false; } } } @@ -220,7 +223,7 @@ export function prepareDiffData(diffData) { const linesLength = file.highlighted_diff_lines.length; for (let u = 0; u < linesLength; u += 1) { const line = file.highlighted_diff_lines[u]; - Object.assign(line, { ...trimFirstCharOfLineContent(line) }); + Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false }); } showingLines += file.parallel_diff_lines.length; } @@ -322,5 +325,9 @@ export const generateTreeList = files => export const getDiffMode = diffFile => { const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]); - return diffModes[diffModeKey] || diffModes.replaced; + return ( + diffModes[diffModeKey] || + (diffFile.mode_changed && diffModes.mode_changed) || + diffModes.replaced + ); }; diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js new file mode 100644 index 00000000000..5185b019376 --- /dev/null +++ b/app/assets/javascripts/dismissable_callout.js @@ -0,0 +1,27 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import Flash from '~/flash'; + +export default function initDismissableCallout(alertSelector) { + const alertEl = document.querySelector(alertSelector); + if (!alertEl) { + return; + } + + const closeButtonEl = alertEl.getElementsByClassName('close')[0]; + const { dismissEndpoint, featureId } = closeButtonEl.dataset; + + closeButtonEl.addEventListener('click', () => { + axios + .post(dismissEndpoint, { + feature_name: featureId, + }) + .then(() => { + $(alertEl).alert('close'); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + }); +} diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 5164d87c5fa..533e90e2222 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -70,7 +70,7 @@ export default { <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon :size="2" /> + <gl-loading-icon :size="2" class="prepend-top-16" /> </div> <template v-else> diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index bb0ecb8efe7..b494b7e2de0 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -88,11 +88,16 @@ export const conditions = [ value: 'started', }, { - url: 'label_name[]=No+Label', + url: 'label_name[]=None', tokenKey: 'label', value: 'none', }, { + url: 'label_name[]=Any', + tokenKey: 'any', + value: 'any', + }, + { url: 'my_reaction_emoji=None', tokenKey: 'my-reaction', value: 'none', diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index b4a3037c1b7..2049760fe29 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -10,13 +10,18 @@ export default function groupsSelect() { const $select = $(this); const allAvailable = $select.data('allAvailable'); const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; + $select.select2({ placeholder: 'Search for a group', allowClear: $select.hasClass('allowClear'), multiple: $select.hasClass('multiselect'), minimumInputLength: 0, ajax: { - url: Api.buildUrl(Api.groupsPath), + url: Api.buildUrl(groupsPath), dataType: 'json', quietMillis: 250, transport(params) { diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index e318367a5ec..7a57ccf2dd3 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -105,7 +105,7 @@ export default { :key="tabView.name" class="h-100" > - <component :is="tabView.name" /> + <component :is="tabView.component || tabView.name" /> </div> </resizable-panel> <nav class="ide-activity-bar"> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 3b201f006aa..09245ed0296 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -26,6 +26,7 @@ export const diffModes = { new: 'new', deleted: 'deleted', renamed: 'renamed', + mode_changed: 'mode_changed', }; export const rightSidebarViews = { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index fbf944499d5..6351948f750 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; +import _ from 'underscore'; import Translate from '~/vue_shared/translate'; import ide from './components/ide.vue'; import store from './stores'; @@ -13,19 +14,19 @@ Vue.use(Translate); * * @param {Element} el - The element that will contain the IDE. * @param {Object} options - Extra options for the IDE (Used by EE). - * @param {(e:Element) => Object} options.extraInitialData - - * Function that returns extra properties to seed initial data. * @param {Component} options.rootComponent - * Component that overrides the root component. + * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore - + * Function that receives the default store and returns an extended one. */ export function initIde(el, options = {}) { if (!el) return null; - const { extraInitialData = () => ({}), rootComponent = ide } = options; + const { rootComponent = ide, extendStore = _.identity } = options; return new Vue({ el, - store, + store: extendStore(store, el), router, created() { this.setEmptyStateSvgs({ @@ -41,7 +42,6 @@ export function initIde(el, options = {}) { }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), - ...extraInitialData(el), }); }, methods: { diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index f0193d8e8ea..13449592e62 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -41,13 +41,13 @@ export default { return Api.project(`${namespace}/${project}`); }, getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { - return Api.mergeRequest(projectId, mergeRequestId, params); + return Api.projectMergeRequest(projectId, mergeRequestId, params); }, getProjectMergeRequestChanges(projectId, mergeRequestId) { - return Api.mergeRequestChanges(projectId, mergeRequestId); + return Api.projectMergeRequestChanges(projectId, mergeRequestId); }, getProjectMergeRequestVersions(projectId, mergeRequestId) { - return Api.mergeRequestVersions(projectId, mergeRequestId); + return Api.projectMergeRequestVersions(projectId, mergeRequestId); }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index 4565c11a83f..8b5f7558654 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -23,13 +23,19 @@ export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search } export const receiveMergeRequestsSuccess = ({ commit }, data) => commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data); -export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => { +export const fetchMergeRequests = ( + { dispatch, state: { state }, rootState: { currentProjectId } }, + { type, search = '' }, +) => { dispatch('requestMergeRequests'); dispatch('resetMergeRequests'); - const scope = type ? scopes[type] : 'all'; + const scope = type && scopes[type]; + const request = scope + ? Api.mergeRequests({ scope, state, search }) + : Api.projectMergeRequest(currentProjectId, '', { state, search }); - return Api.mergeRequests({ scope, state, search }) + return request .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data)) .catch(() => dispatch('receiveMergeRequestsError', { type, search })); }; diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 2d09cf5760f..f7fbb9503a0 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -128,7 +128,7 @@ export default { }; </script> <template> - <div class="prepend-top-default js-environment-container"> + <div class="prepend-top-default append-bottom-default js-environment-container"> <div class="environment-information"> <ci-icon :status="iconStatus" /> <p class="inline append-bottom-0" v-html="environment"></p> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index 7b077d5e621..ec52d272168 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -28,20 +28,22 @@ export default { <div class="bs-callout bs-callout-warning"> <p v-if="tags.length" class="js-stuck-with-tags append-bottom-0"> {{ - s__(`This job is stuck, because you don't have + s__(`This job is stuck because you don't have any active runners online with any of these tags assigned to them:`) }} - <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary"> {{ tag }} </span> + <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> + {{ tag }} + </span> </p> <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners append-bottom-0"> {{ - s__(`Job|This job is stuck, because the project + s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`) }} </p> <p v-else class="js-stuck-no-active-runner append-bottom-0"> {{ - s__(`This job is stuck, because you don't + s__(`This job is stuck because you don't have any active runners that can run this job.`) }} </p> diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 6f42382246d..7933c234384 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -7,3 +7,8 @@ export const addClassIfElementExists = (element, className) => { }; export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage(); + +export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin; + +export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) => + scrollTop + offsetHeight < scrollHeight - margin; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js new file mode 100644 index 00000000000..b41ffb44971 --- /dev/null +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -0,0 +1,13 @@ +export default (buttonSelector, fileSelector) => { + const btn = document.querySelector(buttonSelector); + const fileInput = document.querySelector(fileSelector); + const form = btn.closest('form'); + + btn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', () => { + form.querySelector('.js-filename').textContent = fileInput.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape + }); +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index e4852c85378..14c02218990 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -16,7 +16,9 @@ const httpStatusCodes = { IM_USED: 226, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + FORBIDDEN: 403, NOT_FOUND: 404, + UNPROCESSABLE_ENTITY: 422, }; export const successCodes = [ diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a282c2df441..9850f7ce782 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -17,27 +17,29 @@ export function getParameterValues(sParam) { // @param {Object} params - url keys and value to merge // @param {String} url export function mergeUrlParams(params, url) { - let newUrl = Object.keys(params).reduce((acc, paramName) => { - const paramValue = encodeURIComponent(params[paramName]); - const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`); - - if (paramValue === null) { - return acc.replace(pattern, ''); - } else if (url.search(pattern) !== -1) { - return acc.replace(pattern, `$1${paramValue}$2`); - } - - return `${acc}${acc.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`; - }, decodeURIComponent(url)); + const re = /^([^?#]*)(\?[^#]*)?(.*)/; + const merged = {}; + const urlparts = url.match(re); + + if (urlparts[2]) { + urlparts[2] + .substr(1) + .split('&') + .forEach(part => { + if (part.length) { + const kv = part.split('='); + merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('=')); + } + }); + } - // Remove a trailing ampersand - const lastChar = newUrl[newUrl.length - 1]; + Object.assign(merged, params); - if (lastChar === '&') { - newUrl = newUrl.slice(0, -1); - } + const query = Object.keys(merged) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`) + .join('&'); - return newUrl; + return `${urlparts[1]}?${query}${urlparts[3]}`; } export function removeParamQueryString(url, param) { diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d32f39881dd..75c18a9b6a0 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -155,7 +155,7 @@ export default class MilestoneSelect { const { $el, e } = clickEvent; let selected = clickEvent.selectedObj; - let data, boardsStore; + let data, modalStoreFilter; if (!selected) return; if (options.handleClick) { @@ -179,11 +179,11 @@ export default class MilestoneSelect { } if ($dropdown.closest('.add-issues-modal').length) { - boardsStore = ModalStore.store.filter; + modalStoreFilter = ModalStore.store.filter; } - if (boardsStore) { - boardsStore[$dropdown.data('fieldName')] = selected.name; + if (modalStoreFilter) { + modalStoreFilter[$dropdown.data('fieldName')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { return Issuable.filterResults($dropdown.closest('form')); diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 0d8f31d6bfc..196b84621b6 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -30,6 +30,7 @@ export default class MirrorRepos { this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl()); this.initMirrorSSH(); + this.updateProtectedBranches(); } initMirrorSSH() { diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 815063237fc..64a1df80a8e 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -105,6 +105,9 @@ export default { deploymentFlagData() { return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); }, + shouldRenderData() { + return this.graphData.queries.filter(s => s.result.length > 0).length > 0; + }, }, watch: { hoverData() { @@ -120,17 +123,17 @@ export default { }, draw() { const breakpointSize = bp.getBreakpointSize(); - const query = this.graphData.queries[0]; const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; + 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 = svgWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; this.baseGraphHeight = this.graphHeight - 50; @@ -139,8 +142,15 @@ export default { // pixel offsets inside the svg and outside are not 1:1 this.realPixelRatio = svgWidth / this.baseGraphWidth; - this.renderAxesPaths(); - this.formatDeployments(); + // set the legends on the axes + const [query] = this.graphData.queries; + this.legendTitle = query ? query.label : 'Average'; + this.unitOfDisplay = query ? query.unit : ''; + + if (this.shouldRenderData) { + this.renderAxesPaths(); + this.formatDeployments(); + } }, handleMouseOverGraph(e) { let point = this.$refs.graphData.createSVGPoint(); @@ -266,7 +276,7 @@ export default { :y-axis-label="yAxisLabel" :unit-of-display="unitOfDisplay" /> - <svg ref="graphData" :viewBox="innerViewBox" class="graph-data"> + <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data"> <slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> <graph-path v-for="(path, index) in timeSeries" @@ -293,8 +303,14 @@ export default { @mousemove="handleMouseOverGraph($event);" /> </svg> + <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display"> + <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle"> + {{ s__('Metrics|No data to display') }} + </text> + </svg> </svg> <graph-flag + v-if="shouldRenderData" :real-pixel-ratio="realPixelRatio" :current-x-coordinate="currentXCoordinate" :current-data="currentData" diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 176f7d9eef2..8692c873a41 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -7,10 +7,29 @@ function sortMetrics(metrics) { .value(); } +function checkQueryEmptyData(query) { + return { + ...query, + result: query.result.filter(timeSeries => { + const newTimeSeries = timeSeries; + const hasValue = series => + !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + const hasNonNullValue = timeSeries.values.find(hasValue); + + newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; + + return newTimeSeries.values.length > 0; + }), + }; +} + +function removeTimeSeriesNoData(queries) { + return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); +} + function normalizeMetrics(metrics) { - return metrics.map(metric => ({ - ...metric, - queries: metric.queries.map(query => ({ + return metrics.map(metric => { + const queries = metric.queries.map(query => ({ ...query, result: query.result.map(result => ({ ...result, @@ -19,8 +38,13 @@ function normalizeMetrics(metrics) { value: Number(value), })), })), - })), - })); + })); + + return { + ...metric, + queries: removeTimeSeriesNoData(queries), + }; + }); } export default class MonitoringStore { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 884ccca7bde..ce56beb1e6b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -4,6 +4,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; import { __, sprintf } from '~/locale'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import Autosave from '../../autosave'; import { @@ -30,6 +31,7 @@ export default { markdownField, userAvatarLink, loadingButton, + TimelineEntryItem, }, mixins: [issuableStateMixin], props: { @@ -245,15 +247,19 @@ Please check your network connection and try again.`; } else { this.reopenIssue() .then(() => this.enableButton()) - .catch(() => { + .catch(({ data }) => { this.enableButton(); this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), + let errorMessage = sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, ); + + if (data) { + errorMessage = Object.values(data).join('\n'); + } + + Flash(errorMessage); }); } }, @@ -309,137 +315,135 @@ Please check your network connection and try again.`; <div> <note-signed-out-widget v-if="!isLoggedIn" /> <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> - <div v-else-if="canCreateNote" class="notes notes-form timeline"> - <div class="timeline-entry note-form"> - <div class="timeline-entry-inner"> - <div class="flash-container error-alert timeline-content"></div> - <div class="timeline-icon d-none d-sm-none d-md-block"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content timeline-content-form"> - <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> - <div class="error-alert"></div> + <ul v-else-if="canCreateNote" class="notes notes-form timeline"> + <timeline-entry-item class="note-form"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon d-none d-sm-none d-md-block"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> + <div class="error-alert"></div> - <issue-warning - v-if="hasWarning(getNoteableData)" - :is-locked="isLocked(getNoteableData)" - :is-confidential="isConfidential(getNoteableData)" - /> + <issue-warning + v-if="hasWarning(getNoteableData)" + :is-locked="isLocked(getNoteableData)" + :is-confidential="isConfidential(getNoteableData)" + /> - <markdown-field - ref="markdownField" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" - :add-spacing-classes="false" - > - <textarea - id="note-body" - ref="textarea" - slot="textarea" - v-model="note" - :disabled="isSubmitting" - name="note[note]" - class="note-textarea js-vue-comment-form js-note-text + <markdown-field + ref="markdownField" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :markdown-version="markdownVersion" + :add-spacing-classes="false" + > + <textarea + id="note-body" + ref="textarea" + slot="textarea" + v-model="note" + :disabled="isSubmitting" + name="note[note]" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" - data-supports-quick-actions="true" - aria-label="Description" - placeholder="Write a comment or drag your files here…" - @keydown.up="editCurrentUserLastNote();" - @keydown.meta.enter="handleSave();" - @keydown.ctrl.enter="handleSave();" - > - </textarea> - </markdown-field> - <div class="note-form-actions"> - <div - class="float-left btn-group + data-supports-quick-actions="true" + aria-label="Description" + placeholder="Write a comment or drag your files here…" + @keydown.up="editCurrentUserLastNote();" + @keydown.meta.enter="handleSave();" + @keydown.ctrl.enter="handleSave();" + > + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div + class="float-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" - > - <button - :disabled="isSubmitButtonDisabled" - class="btn btn-create comment-btn js-comment-button js-comment-submit-button + > + <button + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button qa-comment-button" - type="submit" - @click.prevent="handleSave();" - > - {{ __(commentButtonTitle) }} - </button> - <button - :disabled="isSubmitButtonDisabled" - name="button" - type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" - data-display="static" - data-toggle="dropdown" - aria-label="Open comment type dropdown" - > - <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> - </button> - - <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> - <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> - <button - type="button" - class="btn btn-transparent" - @click.prevent="setNoteType('comment');" - > - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>Comment</strong> - <p>Add a general comment to this {{ noteableDisplayName }}.</p> - </div> - </button> - </li> - <li class="divider droplab-item-ignore"></li> - <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button - type="button" - class="btn btn-transparent qa-discussion-option" - @click.prevent="setNoteType('discussion');" - > - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>Start discussion</strong> - <p>{{ startDiscussionDescription }}</p> - </div> - </button> - </li> - </ul> - </div> - - <loading-button - v-if="canUpdateIssue" - :loading="isToggleStateButtonLoading" - :container-class="[ - actionButtonClassNames, - 'btn btn-comment btn-comment-and-close js-action-button', - ]" - :disabled="isToggleStateButtonLoading || isSubmitting" - :label="issueActionButtonTitle" - @click="handleSave(true);" - /> - + type="submit" + @click.prevent="handleSave();" + > + {{ __(commentButtonTitle) }} + </button> <button - v-if="note.length" + :disabled="isSubmitButtonDisabled" + name="button" type="button" - class="btn btn-cancel js-note-discard" - @click="discard" + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + data-display="static" + data-toggle="dropdown" + aria-label="Open comment type dropdown" > - Discard draft + <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Comment</strong> + <p>Add a general comment to this {{ noteableDisplayName }}.</p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent qa-discussion-option" + @click.prevent="setNoteType('discussion');" + > + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>Start discussion</strong> + <p>{{ startDiscussionDescription }}</p> + </div> + </button> + </li> + </ul> </div> - </form> - </div> + + <loading-button + v-if="canUpdateIssue" + :loading="isToggleStateButtonLoading" + :container-class="[ + actionButtonClassNames, + 'btn btn-comment btn-comment-and-close js-action-button', + ]" + :disabled="isToggleStateButtonLoading || isSubmitting" + :label="issueActionButtonTitle" + @click="handleSave(true);" + /> + + <button + v-if="note.length" + type="button" + class="btn btn-cancel js-note-discard" + @click="discard" + > + Discard draft + </button> + </div> + </form> </div> - </div> - </div> + </timeline-entry-item> + </ul> </div> </template> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 8e8bd150647..af821df0fd2 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -4,7 +4,9 @@ import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { GlSkeletonLoading } from '@gitlab/ui'; -import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; +import { getDiffMode } from '~/diffs/store/utils'; + +const FIRST_CHAR_REGEX = /^(\+|-| )/; export default { components: { @@ -26,46 +28,16 @@ export default { }, computed: { ...mapState({ - noteableData: state => state.notes.noteableData, projectPath: state => state.diffs.projectPath, }), diffMode() { - return getDiffMode(this.diffFile); + return getDiffMode(this.discussion.diff_file); }, hasTruncatedDiffLines() { return ( this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 ); }, - isDiscussionsExpanded() { - return true; // TODO: @fatihacet - Fix this. - }, - isCollapsed() { - return this.diffFile.collapsed || false; - }, - isImageDiff() { - return !this.diffFile.text; - }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; - }, - diffFile() { - return this.discussion.diff_file; - }, - imageDiffHtml() { - return this.discussion.image_diff_html; - }, - userColorScheme() { - return window.gon.user_color_scheme; - }, - normalizedDiffLines() { - if (this.discussion.truncated_diff_lines) { - return this.discussion.truncated_diff_lines.map(line => trimFirstCharOfLineContent(line)); - } - - return []; - }, }, mounted() { if (!this.hasTruncatedDiffLines) { @@ -74,9 +46,6 @@ export default { }, methods: { ...mapActions(['fetchDiscussionDiffLines']), - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; - }, fetchDiff() { this.error = false; this.fetchDiscussionDiffLines(this.discussion) @@ -85,31 +54,45 @@ export default { this.error = true; }); }, + trimChar(line) { + return line.replace(FIRST_CHAR_REGEX, ''); + }, }, + userColorSchemeClass: window.gon.user_color_scheme, }; </script> <template> - <div ref="fileHolder" :class="diffFileClass" class="diff-file file-holder"> + <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder"> <diff-file-header :discussion-path="discussion.discussion_path" - :diff-file="diffFile" + :diff-file="discussion.diff_file" :can-current-user-fork="false" - :discussions-expanded="isDiscussionsExpanded" - :expanded="!isCollapsed" + :expanded="!discussion.diff_file.collapsed" /> - <div v-if="diffFile.text" :class="userColorScheme" class="diff-content code"> + <div + v-if="discussion.diff_file.text" + :class="$options.userColorSchemeClass" + class="diff-content code" + > <table> - <tr v-for="line in normalizedDiffLines" :key="line.line_code" class="line_holder"> - <td class="diff-line-num old_line">{{ line.old_line }}</td> - <td class="diff-line-num new_line">{{ line.new_line }}</td> - <td :class="line.type" class="line_content" v-html="line.rich_text"></td> - </tr> + <template v-if="hasTruncatedDiffLines"> + <tr + v-for="line in discussion.truncated_diff_lines" + v-once + :key="line.line_code" + class="line_holder" + > + <td class="diff-line-num old_line">{{ line.old_line }}</td> + <td class="diff-line-num new_line">{{ line.new_line }}</td> + <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td> + </tr> + </template> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> <td class="old_line diff-line-num"></td> <td class="new_line diff-line-num"></td> <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> - Unable to load the diff + {{ error }} Unable to load the diff <button class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" @click="fetchDiff" @@ -131,17 +114,17 @@ export default { <div v-else> <diff-viewer :diff-mode="diffMode" - :new-path="diffFile.new_path" - :new-sha="diffFile.diff_refs.head_sha" - :old-path="diffFile.old_path" - :old-sha="diffFile.diff_refs.base_sha" - :file-hash="diffFile.file_hash" + :new-path="discussion.diff_file.new_path" + :new-sha="discussion.diff_file.diff_refs.head_sha" + :old-path="discussion.diff_file.old_path" + :old-sha="discussion.diff_file.diff_refs.base_sha" + :file-hash="discussion.diff_file.file_hash" :project-path="projectPath" > <image-diff-overlay slot="image-overlay" :discussions="discussion" - :file-hash="diffFile.file_hash" + :file-hash="discussion.diff_file.file_hash" :show-comment-icon="true" :should-toggle-discussion="false" badge-class="image-comment-badge" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index ee79ecbf9b3..c7cfc0f0f3b 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,13 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize } from '../../lib/utils/text_utility'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { Icon, @@ -17,9 +16,9 @@ export default { ...mapGetters([ 'getUserData', 'getNoteableData', - 'discussionCount', + 'resolvableDiscussionsCount', 'firstUnresolvedDiscussionId', - 'resolvedDiscussionCount', + 'unresolvedDiscussionsCount', ]), isLoggedIn() { return this.getUserData.id; @@ -27,15 +26,15 @@ export default { hasNextButton() { return this.isLoggedIn && !this.allResolved; }, - countText() { - return pluralize('discussion', this.discussionCount); - }, allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; + return this.unresolvedDiscussionsCount === 0; }, resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, + resolvedDiscussionsCount() { + return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; + }, }, methods: { ...mapActions(['expandDiscussion']), @@ -50,7 +49,7 @@ export default { </script> <template> - <div v-if="discussionCount > 0" class="line-resolve-all-container prepend-top-8"> + <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8"> <div> <div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all"> <span @@ -61,15 +60,15 @@ export default { <icon name="check-circle" /> </span> <span class="line-resolve-text"> - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved + {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} + {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }} </span> </div> <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group"> <a - v-tooltip + v-gl-tooltip :href="resolveAllDiscussionsIssuePath" :title="s__('Resolve all discussions in new issue')" - data-container="body" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" > <icon name="issue-new" /> @@ -77,9 +76,8 @@ export default { </div> <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group"> <button - v-tooltip + v-gl-tooltip title="Jump to first unresolved discussion" - data-container="body" class="btn btn-default discussion-next-btn" @click="jumpToFirstUnresolvedDiscussion" > diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 9a5817890c9..d99694b06e9 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'NoteActions', @@ -11,7 +10,7 @@ export default { GlLoadingIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { authorId: { @@ -119,10 +118,10 @@ export default { <template> <div class="note-actions"> - <span v-if="accessLevel" class="note-role user-access-role"> {{ accessLevel }} </span> + <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span> <div v-if="canResolve" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" :aria-label="resolveButtonTitle" @@ -138,12 +137,10 @@ export default { </div> <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-tooltip + v-gl-tooltip.bottom :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" data-position="right" - data-placement="bottom" - data-container="body" href="#" title="Add reaction" > @@ -158,12 +155,10 @@ export default { </div> <div v-if="canEdit" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" - data-container="body" - data-placement="bottom" @click="onEdit" > <icon name="pencil" css-classes="link-highlight" /> @@ -171,12 +166,10 @@ export default { </div> <div v-if="showDeleteAction" class="note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" - data-container="body" - data-placement="bottom" @click="onDelete" > <icon name="remove" class="link-highlight" /> @@ -184,19 +177,17 @@ export default { </div> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-tooltip + v-gl-tooltip.bottom type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" - data-container="body" - data-placement="bottom" > <icon css-classes="icon" name="ellipsis_v" /> </button> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> - <a :href="reportAbusePath"> {{ __('Report abuse to GitLab') }} </a> + <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a> </li> <li v-if="noteUrl"> <button @@ -213,7 +204,7 @@ export default { type="button" @click.prevent="onDelete" > - <span class="text-danger"> {{ __('Delete comment') }} </span> + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 4aba2e65edb..3d60eb02db8 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,16 +1,16 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import { glEmojiTag } from '../../emoji'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { components: { Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { awards: { @@ -167,21 +167,19 @@ export default { <button v-for="(awardList, awardName, index) in groupedAwards" :key="index" - v-tooltip + v-gl-tooltip.bottom="{ boundary: 'viewport' }" :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" class="btn award-control" - data-boundary="viewport" - data-placement="bottom" type="button" @click="handleAward(awardName);" > <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter"> {{ awardList.length }} </span> + <span class="award-control-text js-counter">{{ awardList.length }}</span> </button> <div v-if="canAwardEmoji" class="award-menu-holder"> <button - v-tooltip + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index ad58267b533..95164183ccb 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -48,13 +48,19 @@ export default { required: false, default: '', }, + resolveDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, - isResolving: false, + isResolving: this.resolveDiscussion, + isUnresolving: !this.resolveDiscussion, resolveAsThread: true, }; }, @@ -149,7 +155,7 @@ export default { <div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form"> <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a :href="noteHash" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure + <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure information is not lost. </div> <div class="flash-container timeline-content"></div> @@ -174,22 +180,20 @@ export default { v-model="updatedNoteBody" :data-supports-quick-actions="!isEditing" name="note[note]" - class="note-textarea js-gfm-input js-note-text -js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" aria-label="Description" placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleUpdate();" @keydown.ctrl.enter="handleUpdate();" @keydown.up="editMyLastNote();" @keydown.esc="cancelHandler(true);" - > - </textarea> + ></textarea> </markdown-field> <div class="note-form-actions clearfix"> <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button " + class="js-vue-issue-save btn btn-success js-comment-button" @click="handleUpdate();" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 8b7450783c9..e1a58e7cb26 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,7 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" :href="author.path"> + <a v-if="hasAuthor" v-once :href="author.path"> <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span class="note-headline-light"> @{{ author.username }} </span> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 29740ddf6ae..f4991a41325 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,9 +1,12 @@ <script> +import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; +import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; import icon from '~/vue_shared/components/icon.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -20,14 +23,12 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; -import tooltip from '../../vue_shared/directives/tooltip'; export default { name: 'NoteableDiscussion', components: { icon, noteableNote, - diffWithNote, userAvatarLink, noteHeader, noteSignedOutWidget, @@ -37,9 +38,10 @@ export default { placeholderNote, placeholderSystemNote, systemNote, + TimelineEntryItem, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [autosave, noteable, resolvable, discussionNavigation], props: { @@ -64,43 +66,24 @@ export default { }, }, data() { + const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; + return { isReplying: false, isResolving: false, resolveAsThread: true, - isRepliesToggledByUser: false, + isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved), }; }, computed: { ...mapGetters([ 'getNoteableData', - 'discussionCount', - 'resolvedDiscussionCount', - 'allDiscussions', - 'unresolvedDiscussionsIdsByDiff', - 'unresolvedDiscussionsIdsByDate', - 'unresolvedDiscussions', - 'unresolvedDiscussionsIdsOrdered', 'nextUnresolvedDiscussionId', - 'isLastUnresolvedDiscussion', + 'unresolvedDiscussionsCount', + 'hasUnresolvedDiscussions', ]), - transformedDiscussion() { - return { - ...this.discussion.notes[0], - truncated_diff_lines: this.discussion.truncated_diff_lines || [], - truncated_diff_lines_path: this.discussion.truncated_diff_lines_path, - diff_file: this.discussion.diff_file, - diff_discussion: this.discussion.diff_discussion, - active: this.discussion.active, - discussion_path: this.discussion.discussion_path, - resolved: this.discussion.resolved, - resolved_by: this.discussion.resolved_by, - resolved_by_push: this.discussion.resolved_by_push, - resolved_at: this.discussion.resolved_at, - }; - }, author() { - return this.transformedDiscussion.author; + return this.initialDiscussion.author; }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -136,29 +119,13 @@ export default { return null; }, resolvedText() { - return this.transformedDiscussion.resolved_by_push ? 'Automatically resolved' : 'Resolved'; - }, - hasMultipleUnresolvedDiscussions() { - return this.unresolvedDiscussions.length > 1; - }, - showJumpToNextDiscussion() { - return ( - this.hasMultipleUnresolvedDiscussions && - !this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder) - ); + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, shouldRenderDiffs() { - return ( - this.transformedDiscussion.diff_discussion && - this.transformedDiscussion.diff_file && - this.renderDiffFile - ); + return this.discussion.diff_discussion && this.renderDiffFile; }, shouldGroupReplies() { - return !this.shouldRenderDiffs && !this.transformedDiscussion.diff_discussion; - }, - shouldRenderHeader() { - return this.shouldRenderDiffs; + return !this.shouldRenderDiffs && !this.discussion.diff_discussion; }, wrapperComponent() { return this.shouldRenderDiffs ? diffWithNote : 'div'; @@ -170,9 +137,6 @@ export default { return {}; }, - wrapperClass() { - return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; - }, componentClassName() { if (this.shouldRenderDiffs) { if (!this.lastUpdatedAt && !this.discussion.resolved) { @@ -183,19 +147,40 @@ export default { return ''; }, shouldShowDiscussions() { - const isExpanded = this.discussion.expanded; - const { resolved } = this.transformedDiscussion; - const isResolvedNonDiffDiscussion = !this.transformedDiscussion.diff_discussion && resolved; + const { expanded, resolved } = this.discussion; + const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved; - return isExpanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; + return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion; }, - isRepliesCollapsed() { - const { discussion, isRepliesToggledByUser } = this; - const { resolved, notes } = discussion; - const hasReplies = notes.length > 1; + actionText() { + const commitId = this.discussion.commit_id ? truncateSha(this.discussion.commit_id) : ''; + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + let text = s__('MergeRequests|started a discussion'); - return ( - (!discussion.diff_discussion && resolved && hasReplies && !isRepliesToggledByUser) || false + if (this.discussion.for_commit) { + text = s__( + 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', + ); + } else if (this.discussion.diff_discussion) { + if (this.discussion.active) { + text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}'); + } else { + text = s__( + 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + } + + return sprintf( + text, + { + commitId, + linkStart, + linkEnd, + }, + false, ); }, }, @@ -204,7 +189,7 @@ export default { if (this.isReplying) { this.$nextTick(() => { // Pass an extra key to separate reply and note edit forms - this.initAutoSave(this.transformedDiscussion, ['Reply']); + this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']); }); } else { this.disposeAutoSave(); @@ -242,7 +227,7 @@ export default { this.toggleDiscussion({ discussionId: this.discussion.id }); }, toggleReplies() { - this.isRepliesToggledByUser = !this.isRepliesToggledByUser; + this.isRepliesCollapsed = !this.isRepliesCollapsed; }, showReplyForm() { this.isReplying = true; @@ -311,181 +296,156 @@ Please check your network connection and try again.`; </script> <template> - <li class="note note-discussion timeline-entry" :class="componentClassName"> - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <div - :data-discussion-id="transformedDiscussion.discussion_id" - class="discussion js-discussion-container" - > - <div v-if="shouldRenderHeader" class="discussion-header note-wrapper"> - <div class="timeline-icon"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <note-header - :author="author" - :created-at="transformedDiscussion.created_at" - :note-id="transformedDiscussion.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <template v-if="transformedDiscussion.diff_discussion"> - started a discussion on - <a :href="transformedDiscussion.discussion_path"> - <template v-if="transformedDiscussion.active"> - the diff - </template> - <template v-else> - an old version of the diff - </template> - </a> - </template> - <template v-else-if="discussion.for_commit"> - started a discussion on commit - <a :href="discussion.discussion_path"> {{ truncateSha(discussion.commit_id) }} </a> - </template> - <template v-else> - started a discussion - </template> - </note-header> - <note-edited-text - v-if="transformedDiscussion.resolved" - :edited-at="transformedDiscussion.resolved_at" - :edited-by="transformedDiscussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" + <timeline-entry-item class="note note-discussion" :class="componentClassName"> + <div class="timeline-content"> + <div :data-discussion-id="discussion.id" class="discussion js-discussion-container"> + <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> + <div v-once class="timeline-icon"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" /> </div> - <div v-if="shouldShowDiscussions" class="discussion-body"> - <component :is="wrapperComponent" v-bind="wrapperComponentProps" :class="wrapperClass"> - <div class="discussion-notes"> - <ul class="notes"> - <template v-if="shouldGroupReplies"> - <component - :is="componentName(initialDiscussion)" - :note="componentData(initialDiscussion)" - @handleDeleteNote="deleteNoteHandler" - > - <slot slot="avatar-badge" name="avatar-badge"> </slot> - </component> - <toggle-replies-widget - v-if="hasReplies" - :collapsed="isRepliesCollapsed" - :replies="replies" - @toggle="toggleReplies" - /> - <template v-if="!isRepliesCollapsed"> - <component - :is="componentName(note)" - v-for="note in replies" - :key="note.id" - :note="componentData(note)" - @handleDeleteNote="deleteNoteHandler" - /> - </template> - </template> - <template v-else> + <note-header + :author="author" + :created-at="initialDiscussion.created_at" + :note-id="initialDiscussion.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="actionText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + <div v-if="shouldShowDiscussions" class="discussion-body"> + <component + :is="wrapperComponent" + v-bind="wrapperComponentProps" + class="card discussion-wrapper" + > + <div class="discussion-notes"> + <ul class="notes"> + <template v-if="shouldGroupReplies"> + <component + :is="componentName(initialDiscussion)" + :note="componentData(initialDiscussion)" + @handleDeleteNote="deleteNoteHandler" + > + <slot slot="avatar-badge" name="avatar-badge"></slot> + </component> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="isRepliesCollapsed" + :replies="replies" + @toggle="toggleReplies" + /> + <template v-if="!isRepliesCollapsed"> <component :is="componentName(note)" - v-for="(note, index) in discussion.notes" + v-for="note in replies" :key="note.id" :note="componentData(note)" @handleDeleteNote="deleteNoteHandler" - > - <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"> </slot> - </component> + /> </template> - </ul> - <div - v-if="!isRepliesCollapsed" - :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder" - > - <template v-if="!isReplying && canReply"> - <div class="discussion-with-resolve-btn"> + </template> + <template v-else> + <component + :is="componentName(note)" + v-for="(note, index) in discussion.notes" + :key="note.id" + :note="componentData(note)" + @handleDeleteNote="deleteNoteHandler" + > + <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> + </component> + </template> + </ul> + <div + v-if="!isRepliesCollapsed" + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder" + > + <template v-if="!isReplying && canReply"> + <div class="discussion-with-resolve-btn"> + <button + type="button" + class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply" + title="Add a reply" + @click="showReplyForm" + > + Reply... + </button> + <div v-if="discussion.resolvable"> <button type="button" - class="js-vue-discussion-reply btn btn-text-field mr-sm-2 qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" + class="btn btn-default mr-sm-2" + @click="resolveHandler();" > - Reply... + <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i> + {{ resolveButtonTitle }} </button> - <div v-if="discussion.resolvable"> + </div> + <div + v-if="discussion.resolvable" + class="btn-group discussion-actions ml-sm-2" + role="group" + > + <div v-if="!discussionResolved" class="btn-group" role="group"> + <a + v-gl-tooltip + :href="discussion.resolve_with_issue_path" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" + class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" + > + <icon name="issue-new" /> + </a> + </div> + <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group"> <button - type="button" - class="btn btn-default mr-sm-2" - @click="resolveHandler();" + v-gl-tooltip + class="btn btn-default discussion-next-btn" + title="Jump to next unresolved discussion" + @click="jumpToNextDiscussion" > - <i - v-if="isResolving" - aria-hidden="true" - class="fa fa-spinner fa-spin" - ></i> - {{ resolveButtonTitle }} + <icon name="comment-next" /> </button> </div> - <div - v-if="discussion.resolvable" - class="btn-group discussion-actions ml-sm-2" - role="group" - > - <div v-if="!discussionResolved" class="btn-group" role="group"> - <a - v-tooltip - :href="discussion.resolve_with_issue_path" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" - class="new-issue-for-discussion btn - btn-default discussion-create-issue-btn" - data-container="body" - > - <icon name="issue-new" /> - </a> - </div> - <div v-if="showJumpToNextDiscussion" class="btn-group" role="group"> - <button - v-tooltip - class="btn btn-default discussion-next-btn" - title="Jump to next unresolved discussion" - data-container="body" - @click="jumpToNextDiscussion" - > - <icon name="comment-next" /> - </button> - </div> - </div> </div> - </template> - <note-form - v-if="isReplying" - ref="noteForm" - :discussion="discussion" - :is-editing="false" - save-button-title="Comment" - @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" - /> - <note-signed-out-widget v-if="!canReply" /> - </div> + </div> + </template> + <note-form + v-if="isReplying" + ref="noteForm" + :discussion="discussion" + :is-editing="false" + save-button-title="Comment" + @handleFormUpdate="saveReply" + @cancelForm="cancelReplyForm" + /> + <note-signed-out-widget v-if="!canReply" /> </div> - </component> - </div> + </div> + </component> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index c2e49f8b23f..a17be51353e 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -18,6 +19,7 @@ export default { noteHeader, noteActions, noteBody, + TimelineEntryItem, }, mixins: [noteable, resolvable], props: { @@ -169,61 +171,60 @@ export default { </script> <template> - <li + <timeline-entry-item :id="noteAnchorId" :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note timeline-entry note-wrapper" + class="note note-wrapper" > - <div class="timeline-entry-inner"> - <div class="timeline-icon"> - <user-avatar-link - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - > - <slot slot="avatar-badge" name="avatar-badge"> </slot> - </user-avatar-link> - </div> - <div class="timeline-content"> - <div class="note-header"> - <note-header - :author="author" - :created-at="note.created_at" - :note-id="note.id" - action-text="commented" - /> - <note-actions - :author-id="author.id" - :note-id="note.id" - :note-url="note.noteable_note_url" - :access-level="note.human_access" - :can-edit="note.current_user.can_edit" - :can-award-emoji="note.current_user.can_award_emoji" - :can-delete="note.current_user.can_edit" - :can-report-as-abuse="canReportAsAbuse" - :can-resolve="note.current_user.can_resolve" - :report-abuse-path="note.report_abuse_path" - :resolvable="note.resolvable" - :is-resolved="note.resolved" - :is-resolving="isResolving" - :resolved-by="note.resolved_by" - @handleEdit="editHandler" - @handleDelete="deleteHandler" - @handleResolve="resolveHandler" - /> - </div> - <note-body - ref="noteBody" - :note="note" + <div v-once class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + > + <slot slot="avatar-badge" name="avatar-badge"> </slot> + </user-avatar-link> + </div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + v-once + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <note-actions + :author-id="author.id" + :note-id="note.id" + :note-url="note.noteable_note_url" + :access-level="note.human_access" :can-edit="note.current_user.can_edit" - :is-editing="isEditing" - @handleFormUpdate="formUpdateHandler" - @cancelForm="formCancelHandler" + :can-award-emoji="note.current_user.can_award_emoji" + :can-delete="note.current_user.can_edit" + :can-report-as-abuse="canReportAsAbuse" + :can-resolve="note.current_user.can_resolve" + :report-abuse-path="note.report_abuse_path" + :resolvable="note.resolvable" + :is-resolved="note.resolved" + :is-resolving="isResolving" + :resolved-by="note.resolved_by" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + @handleResolve="resolveHandler" /> </div> + <note-body + ref="noteBody" + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelForm="formCancelHandler" + /> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 79ece036e69..6e6efb04753 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -22,6 +22,7 @@ export default { commentForm, placeholderNote, placeholderSystemNote, + skeletonLoadingContainer, }, props: { noteableData: { @@ -59,7 +60,6 @@ export default { 'isNotesFetched', 'discussions', 'getNotesDataByProp', - 'discussionCount', 'isLoading', 'commentsDisabled', ]), @@ -109,39 +109,22 @@ export default { this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); }, methods: { - ...mapActions({ - setLoadingState: 'setLoadingState', - fetchDiscussions: 'fetchDiscussions', - poll: 'poll', - actionToggleAward: 'toggleAward', - scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', - setNotesData: 'setNotesData', - setNoteableData: 'setNoteableData', - setUserData: 'setUserData', - setLastFetchedAt: 'setLastFetchedAt', - setTargetNoteHash: 'setTargetNoteHash', - toggleDiscussion: 'toggleDiscussion', - setNotesFetchedState: 'setNotesFetchedState', - startTaskList: 'startTaskList', - }), - getComponentName(discussion) { - if (discussion.isSkeletonNote) { - return skeletonLoadingContainer; - } - if (discussion.isPlaceholderNote) { - if (discussion.placeholderType === constants.SYSTEM_NOTE) { - return placeholderSystemNote; - } - return placeholderNote; - } else if (discussion.individual_note) { - return discussion.notes[0].system ? systemNote : noteableNote; - } - - return noteableDiscussion; - }, - getComponentData(discussion) { - return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; - }, + ...mapActions([ + 'setLoadingState', + 'fetchDiscussions', + 'poll', + 'toggleAward', + 'scrollToNoteIfNeeded', + 'setNotesData', + 'setNoteableData', + 'setUserData', + 'setLastFetchedAt', + 'setTargetNoteHash', + 'toggleDiscussion', + 'setNotesFetchedState', + 'expandDiscussion', + 'startTaskList', + ]), fetchNotes() { if (this.isFetching) return null; @@ -181,31 +164,46 @@ export default { const noteId = hash && hash.replace(/^note_/, ''); if (noteId) { - this.discussions.forEach(discussion => { - if (discussion.notes) { - discussion.notes.forEach(note => { - if (`${note.id}` === `${noteId}`) { - // FIXME: this modifies the store state without using a mutation/action - Object.assign(discussion, { expanded: true }); - } - }); - } - }); + const discussion = this.discussions.find(d => d.notes.some(({ id }) => id === noteId)); + + if (discussion) { + this.expandDiscussion({ discussionId: discussion.id }); + } } }, }, + systemNote: constants.SYSTEM_NOTE, }; </script> <template> <div v-show="shouldShow" id="notes"> <ul id="notes-list" class="notes main-notes-list timeline"> - <component - :is="getComponentName(discussion)" - v-for="discussion in allDiscussions" - :key="discussion.id" - v-bind="getComponentData(discussion)" - /> + <template v-for="discussion in allDiscussions"> + <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" /> + <template v-else-if="discussion.isPlaceholderNote"> + <placeholder-system-note + v-if="discussion.placeholderType === $options.systemNote" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <template v-else-if="discussion.individual_note"> + <system-note + v-if="discussion.notes[0].system" + :key="discussion.id" + :note="discussion.notes[0]" + /> + <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + </template> + <noteable-discussion + v-else + :key="discussion.id" + :discussion="discussion" + :render-diff-file="true" + /> + </template> </ul> <comment-form diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index cd8394e0619..8edf3d088bb 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -36,7 +36,7 @@ export default { const discussion = this.resolveAsThread; const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; - this.toggleResolveNote({ endpoint, isResolved, discussion }) + return this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { this.isResolving = false; }) diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5b2f0540020..b4befdd6e4a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -11,7 +11,7 @@ import * as constants from '../constants'; import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; -import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; +import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; @@ -39,12 +39,13 @@ export const setNotesFetchedState = ({ commit }, state) => export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchDiscussions = ({ commit }, { path, filter }) => +export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) => service .fetchDiscussions(path, filter) .then(res => res.json()) .then(discussions => { commit(types.SET_INITIAL_DISCUSSIONS, discussions); + dispatch('updateResolvableDiscussonsCounts'); }); export const updateDiscussion = ({ commit, state }, discussion) => { @@ -53,11 +54,18 @@ export const updateDiscussion = ({ commit, state }, discussion) => { return utils.findNoteObjectById(state.discussions, discussion.id); }; -export const deleteNote = ({ commit, dispatch }, note) => +export const deleteNote = ({ commit, dispatch, state }, note) => service.deleteNote(note.path).then(() => { + const discussion = state.discussions.find(({ id }) => id === note.discussion_id); + commit(types.DELETE_NOTE, note); dispatch('updateMergeRequestWidget'); + dispatch('updateResolvableDiscussonsCounts'); + + if (isInMRPage()) { + dispatch('diffs/removeDiscussionsFromDiff', discussion); + } }); export const updateNote = ({ commit, dispatch }, { endpoint, note }) => @@ -89,6 +97,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) => dispatch('updateMergeRequestWidget'); dispatch('startTaskList'); + dispatch('updateResolvableDiscussonsCounts'); } return res; }); @@ -104,6 +113,8 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, commit(mutationType, res); + dispatch('updateResolvableDiscussonsCounts'); + dispatch('updateMergeRequestWidget'); }); @@ -385,5 +396,8 @@ export const startTaskList = ({ dispatch }) => }), ); +export const updateResolvableDiscussonsCounts = ({ commit }) => + commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 980d79605d7..2ed8aac059a 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -53,30 +53,15 @@ export const getCurrentUserLastNote = state => export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes).find(el => isLastNote(el, state)); -export const discussionCount = state => { - const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); - - return filteredDiscussions.length; -}; - -export const unresolvedDiscussions = (state, getters) => { - const resolvedMap = getters.resolvedDiscussionsById; - - return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]); -}; - -export const allDiscussions = (state, getters) => { - const resolved = getters.resolvedDiscussionsById; - const unresolved = getters.unresolvedDiscussions; - - return Object.values(resolved).concat(unresolved); -}; +export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount; +export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; +export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; export const isDiscussionResolved = (state, getters) => discussionId => getters.resolvedDiscussionsById[discussionId] !== undefined; -export const allResolvableDiscussions = (state, getters) => - getters.allDiscussions.filter(d => !d.individual_note && d.resolvable); +export const allResolvableDiscussions = state => + state.discussions.filter(d => !d.individual_note && d.resolvable); export const resolvedDiscussionsById = state => { const map = {}; @@ -147,15 +132,12 @@ export const resolvedDiscussionCount = (state, getters) => { return Object.keys(resolvedMap).length; }; -export const discussionTabCounter = state => { - let all = []; - - state.discussions.forEach(discussion => { - all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder)); - }); - - return all.length; -}; +export const discussionTabCounter = state => + state.discussions.reduce( + (acc, discussion) => + acc + discussion.notes.filter(note => !note.system && !note.placeholder).length, + 0, + ); // Returns the list of discussion IDs ordered according to given parameter // @param {Boolean} diffOrder - is ordered by diff? @@ -182,8 +164,10 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); const currentIndex = idsOrdered.indexOf(discussionId); + const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2); - return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0]; + // Get the first ID if there is none after the currentIndex + return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; }; // @param {Boolean} diffOrder - is ordered by diff? diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 8aea269ea7d..b5fe8bdb1d3 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -22,6 +22,9 @@ export default () => ({ current_user: {}, }, commentsDisabled: false, + resolvableDiscussionsCount: 0, + unresolvedDiscussionsCount: 0, + hasUnresolvedDiscussions: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index dfbf3b7b34b..9c68ab67a8c 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -21,6 +21,7 @@ export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index f6054e0be87..bea396e5bb6 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -24,6 +24,7 @@ export default { noteData.resolved = false; noteData.resolve_path = note.resolve_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path; + noteData.diff_discussion = false; } state.discussions.push(noteData); @@ -97,33 +98,36 @@ export default { }, [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { - const discussions = []; + const discussions = discussionsData.reduce((acc, d) => { + const discussion = { ...d }; + const diffData = {}; - discussionsData.forEach(discussion => { if (discussion.diff_file) { - Object.assign(discussion, { - file_hash: discussion.diff_file.file_hash, - truncated_diff_lines: discussion.truncated_diff_lines || [], - }); + diffData.file_hash = discussion.diff_file.file_hash; + diffData.truncated_diff_lines = discussion.truncated_diff_lines || []; } // To support legacy notes, should be very rare case. if (discussion.individual_note && discussion.notes.length > 1) { discussion.notes.forEach(n => { - discussions.push({ + acc.push({ ...discussion, + ...diffData, notes: [n], // override notes array to only have one item to mimick individual_note }); }); } else { const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); - discussions.push({ + acc.push({ ...discussion, + ...diffData, expanded: oldNote ? oldNote.expanded : discussion.expanded, }); } - }); + + return acc; + }, []); Object.assign(state, { discussions }); }, @@ -174,9 +178,11 @@ export default { } }, - [types.TOGGLE_DISCUSSION](state, { discussionId }) { + [types.TOGGLE_DISCUSSION](state, { discussionId, forceExpanded = null }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - Object.assign(discussion, { expanded: !discussion.expanded }); + Object.assign(discussion, { + expanded: forceExpanded === null ? !discussion.expanded : forceExpanded, + }); }, [types.UPDATE_NOTE](state, note) { @@ -195,7 +201,9 @@ export default { const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse if (note.diff_file) { - Object.assign(note, { file_hash: note.diff_file.file_hash }); + Object.assign(note, { + file_hash: note.diff_file.file_hash, + }); } Object.assign(selectedDiscussion, { ...note }); }, @@ -229,4 +237,16 @@ export default { [types.DISABLE_COMMENTS](state, value) { state.commentsDisabled = value; }, + [types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](state) { + state.resolvableDiscussionsCount = state.discussions.filter( + discussion => !discussion.individual_note && discussion.resolvable, + ).length; + state.unresolvedDiscussionsCount = state.discussions.filter( + discussion => + !discussion.individual_note && + discussion.resolvable && + discussion.notes.some(note => !note.resolved), + ).length; + state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; + }, }; diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index c4c8cf86cb0..e7fa05faa8a 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -12,6 +12,10 @@ export default function notificationsDropdown() { const form = $(this).parents('.notification-form:first'); form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); + if (form.hasClass('no-label')) { + form.find('.js-notification-loading').toggleClass('hidden'); + form.find('.js-notifications-icon').toggleClass('hidden'); + } form.find('#notification_setting_level').val(notificationLevel); form.submit(); }); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 32b55575f95..01ef445c901 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import { GROUP_BADGE } from '~/badges/constants'; +import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { @@ -17,5 +18,8 @@ document.addEventListener('DOMContentLoaded', () => { ); mountBadgeSettings(GROUP_BADGE); + // Initialize Subgroups selector + groupsSelect(); + projectSelect(); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index 00e2d7fc998..bf80d8b8193 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,12 +1,6 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -function initCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new -} - document.addEventListener('DOMContentLoaded', () => { const { page } = document.body.dataset; const newClusterViews = [ @@ -16,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initCallout(); + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } }); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index f5b1cf85e68..899d5925956 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,8 +3,8 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; +import fileUpload from '~/lib/utils/file_upload'; import initProjectLoadingSpinner from '../shared/save_project_loader'; -import projectAvatar from '../shared/project_avatar'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); - projectAvatar(); + fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); initProjectPermissionsSettings(); initConfirmDangerModal(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index b0345b4e50d..5659e13981a 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -12,9 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index a6bee49a6b1..b288989b252 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -13,6 +13,9 @@ export default class Project { const $cloneOptions = $('ul.clone-options-dropdown'); const $projectCloneField = $('#project_clone'); const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); + const mobileCloneField = document.querySelector( + '.js-mobile-git-clone .js-clone-dropdown-label', + ); const selectedCloneOption = $cloneBtnLabel.text().trim(); if (selectedCloneOption.length > 0) { @@ -36,7 +39,11 @@ export default class Project { $label.text(activeText); }); - $projectCloneField.val(url); + if (mobileCloneField) { + mobileCloneField.dataset.clipboardText = url; + } else { + $projectCloneField.val(url); + } $('.js-git-empty .js-clone').text(url); }); // Ref switcher diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js new file mode 100644 index 00000000000..7b08620773c --- /dev/null +++ b/app/assets/javascripts/pages/projects/serverless/index.js @@ -0,0 +1,5 @@ +import ServerlessBundle from '~/serverless/serverless_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ServerlessBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index a52861c9efa..3e02893f24c 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -7,6 +7,7 @@ import initDeployKeys from '~/deploy_keys'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; import DueDateSelectors from '~/due_date_select'; +import fileUpload from '~/lib/utils/file_upload'; export default () => { new ProtectedTagCreate(); @@ -16,4 +17,5 @@ export default () => { new ProtectedBranchCreate(); new ProtectedBranchEditList(); new DueDateSelectors(); + fileUpload('.js-choose-file', '.js-object-map-input'); }; diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js deleted file mode 100644 index 1e69ecb481d..00000000000 --- a/app/assets/javascripts/pages/projects/shared/project_avatar.js +++ /dev/null @@ -1,16 +0,0 @@ -import $ from 'jquery'; - -export default function projectAvatar() { - $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() { - const form = $(this).closest('form'); - return form.find('.js-project-avatar-input').click(); - }); - - $('.js-project-avatar-input').bind('change', function onClickAvatarInput() { - const form = $(this).closest('form'); - const filename = $(this) - .val() - .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape - return form.find('.js-avatar-filename').text(filename); - }); -} diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index d3e8dbf4000..9b58d42b47d 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,5 +1,4 @@ import bp from '../../../breakpoints'; -import { slugify } from '../../../lib/utils/text_utility'; import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils'; import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility'; @@ -26,7 +25,8 @@ export default class Wikis { if (!this.newWikiForm) return; const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - const slug = slugify(slugInput.value); + + const slug = slugInput.value; if (slug.length > 0) { const wikisPath = slugInput.getAttribute('data-wikis-path'); diff --git a/app/assets/javascripts/pages/root/index.js b/app/assets/javascripts/pages/root/index.js deleted file mode 100644 index 09f8185d3b5..00000000000 --- a/app/assets/javascripts/pages/root/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// if the "projects dashboard" is a user's default dashboard, when they visit the -// instance root index, the dashboard will be served by the root controller instead -// of a dashboard controller. The root index redirects for all other default dashboards. - -import '../dashboard/projects/index'; diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js deleted file mode 100644 index 1e34e74a152..00000000000 --- a/app/assets/javascripts/persistent_user_callout.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; -import Flash from './flash'; - -export default class PersistentUserCallout { - constructor(container) { - const { dismissEndpoint, featureId } = container.dataset; - this.container = container; - this.dismissEndpoint = dismissEndpoint; - this.featureId = featureId; - - this.init(); - } - - init() { - const closeButton = this.container.querySelector('.js-close'); - closeButton.addEventListener('click', event => this.dismiss(event)); - } - - dismiss(event) { - event.preventDefault(); - - axios - .post(this.dismissEndpoint, { - feature_name: this.featureId, - }) - .then(() => { - this.container.remove(); - }) - .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); - }); - } -} diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 6f008528db4..59cebaba717 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -18,23 +18,19 @@ export default { required: true, }, }, - computed: { graph() { return this.pipeline.details && this.pipeline.details.stages; }, }, - methods: { capitalizeStageName(name) { const escapedName = _.escape(name); return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); }, - isFirstColumn(index) { return index === 0; }, - stageConnectorClass(index, stage) { let className; @@ -48,7 +44,6 @@ export default { return className; }, - refreshPipelineGraph() { this.$emit('refreshPipelineGraph'); }, diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 782494f72e4..cf9db89e32b 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -84,10 +84,6 @@ export default { return textBuilder.join(' '); }, - - tooltipBoundary() { - return this.dropdownLength < 5 ? 'viewport' : null; - }, /** * Verifies if the provided job has an action path * @@ -108,7 +104,7 @@ export default { <div class="ci-job-component"> <gl-link v-if="status.has_details" - v-gl-tooltip="{ boundary: tooltipBoundary }" + v-gl-tooltip :href="status.details_path" :title="tooltipText" :class="cssClassJobName" diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index e5924d3a77e..30a5bbf92ce 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -65,7 +65,7 @@ export default { v-if="pipeline.flags.latest" v-gl-tooltip class="js-pipeline-url-latest badge badge-success" - title="Latest pipeline for this branch" + title="__('Latest pipeline for this branch')" > latest </span> @@ -97,6 +97,14 @@ export default { <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> stuck </span> + <span + v-if="pipeline.flags.merge_request" + v-gl-tooltip + title="__('This pipeline is run in a merge request context')" + class="js-pipeline-url-mergerequest badge badge-info" + > + merge request + </span> </div> </div> </template> diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue new file mode 100644 index 00000000000..2683805f2f7 --- /dev/null +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-center"> + {{ s__('Serverless|Getting started with serverless') }} + </h4> + <p class="state-description"> + {{ + s__(`Serverless| In order to start using functions as a service, + you must first install Knative on your Kubernetes cluster.`) + }} + + <a :href="helpPath"> {{ __('More information') }} </a> + </p> + + <div class="text-center"> + <a :href="clustersPath" class="btn btn-success"> + {{ s__('Serverless|Install Knative') }} + </a> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue new file mode 100644 index 00000000000..31f5427c771 --- /dev/null +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -0,0 +1,40 @@ +<script> +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Timeago, + }, + props: { + func: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.func.name; + }, + url() { + return this.func.url; + }, + image() { + return this.func.image; + }, + timestamp() { + return this.func.created_at; + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-20">{{ name }}</div> + <div class="table-section section-50"> + <a :href="url">{{ url }}</a> + </div> + <div class="table-section section-20">{{ image }}</div> + <div class="table-section section-10"><timeago :time="timestamp" /></div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue new file mode 100644 index 00000000000..7874a7b6b6a --- /dev/null +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -0,0 +1,123 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import FunctionRow from './function_row.vue'; +import EmptyState from './empty_state.vue'; + +export default { + components: { + FunctionRow, + EmptyState, + GlSkeletonLoading, + }, + props: { + functions: { + type: Array, + required: true, + default: () => [], + }, + installed: { + type: Boolean, + required: true, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + loadingData: { + type: Boolean, + required: false, + default: true, + }, + hasFunctionData: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> + +<template> + <section id="serverless-functions"> + <div v-if="installed"> + <div v-if="hasFunctionData"> + <div class="ci-table js-services-list function-element"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Function') }} + </div> + <div class="table-section section-50" role="rowheader"> + {{ s__('Serverless|Domain') }} + </div> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Runtime') }} + </div> + <div class="table-section section-10" role="rowheader"> + {{ s__('Serverless|Last Update') }} + </div> + </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> + <gl-skeleton-loading /> + </div> + </template> + <template v-else> + <function-row v-for="f in functions" :key="f.name" :func="f" /> + </template> + </div> + </div> + <div v-else class="empty-state js-empty-state"> + <div class="text-content"> + <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> + <p class="state-description"> + {{ + s__(`Serverless|There is currently no function data available from Knative. + This could be for a variety of reasons including:`) + }} + </p> + <ul> + <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> + <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li> + <li> + The functions listed in the <code>serverless.yml</code> file don't match the namespace + of your cluster. + </li> + <li>The deploy job has not finished.</li> + </ul> + + <p> + {{ + s__(`Serverless|If you believe none of these apply, please check + back later as the function data may be in the process of becoming + available.`) + }} + </p> + <div class="text-center"> + <a :href="helpPath" class="btn btn-success"> + {{ s__('Serverless|Learn more about Serverless') }} + </a> + </div> + </div> + </div> + </div> + + <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> + </section> +</template> + +<style> +.top-area { + border-bottom: 0; +} + +.function-element { + border-bottom: 1px solid #e5e5e5; + border-bottom-color: rgb(229, 229, 229); + border-bottom-style: solid; + border-bottom-width: 1px; +} +</style> diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/serverless/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js new file mode 100644 index 00000000000..3e3b81ba247 --- /dev/null +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -0,0 +1,106 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__ } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import ServerlessStore from './stores/serverless_store'; +import GetFunctionsService from './services/get_functions_service'; +import Functions from './components/functions.vue'; + +export default class Serverless { + constructor() { + const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + '.js-serverless-functions-page', + ).dataset; + + this.service = new GetFunctionsService(statusPath); + this.knativeInstalled = installed !== undefined; + this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); + this.initServerless(); + this.functionLoadCount = 0; + + if (statusPath && this.knativeInstalled) { + this.initPolling(); + } + } + + initServerless() { + const { store } = this; + const el = document.querySelector('#js-serverless-functions'); + + this.functions = new Vue({ + el, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement(Functions, { + props: { + functions: this.state.functions, + installed: this.state.installed, + clustersPath: this.state.clustersPath, + helpPath: this.state.helpPath, + loadingData: this.state.loadingData, + hasFunctionData: this.state.hasFunctionData, + }, + }); + }, + }); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => this.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service + .fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => this.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + handleSuccess(data) { + if (data.status === 200) { + this.store.updateFunctionsFromServer(data.data); + this.store.updateLoadingState(false); + } else if (data.status === 204) { + /* Time out after 3 attempts to retrieve data */ + this.functionLoadCount += 1; + if (this.functionLoadCount === 3) { + this.poll.stop(); + this.store.toggleNoFunctionData(); + } + } + } + + static handleError() { + Flash(s__('Serverless|An error occurred while retrieving serverless components')); + } + + destroy() { + this.destroyed = true; + + if (this.poll) { + this.poll.stop(); + } + + this.functions.$destroy(); + } +} diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js new file mode 100644 index 00000000000..303b42dc66c --- /dev/null +++ b/app/assets/javascripts/serverless/services/get_functions_service.js @@ -0,0 +1,11 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class GetFunctionsService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchData() { + return axios.get(this.endpoint); + } +} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js new file mode 100644 index 00000000000..774c15b5b12 --- /dev/null +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -0,0 +1,24 @@ +export default class ServerlessStore { + constructor(knativeInstalled = false, clustersPath, helpPath) { + this.state = { + functions: [], + hasFunctionData: true, + loadingData: true, + installed: knativeInstalled, + clustersPath, + helpPath, + }; + } + + updateFunctionsFromServer(functions = []) { + this.state.functions = functions; + } + + updateLoadingState(loadingData) { + this.state.loadingData = loadingData; + } + + toggleNoFunctionData() { + this.state.hasFunctionData = false; + } +} diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 007b83e1927..9af5d5b23cb 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -23,11 +23,11 @@ export default class Star { if (isStarred) { $starSpan.removeClass('starred').text(s__('StarProject|Star')); $startIcon.remove(); - $this.prepend(spriteIcon('star-o')); + $this.prepend(spriteIcon('star-o', 'icon')); } else { $starSpan.addClass('starred').text(__('Unstar')); $startIcon.remove(); - $this.prepend(spriteIcon('star')); + $this.prepend(spriteIcon('star', 'icon')); } }) .catch(() => Flash('Star toggle failed. Try again later.')); diff --git a/app/assets/javascripts/terminal/index.js b/app/assets/javascripts/terminal/index.js index 49aeb377c74..8faff59fd45 100644 --- a/app/assets/javascripts/terminal/index.js +++ b/app/assets/javascripts/terminal/index.js @@ -1,3 +1,3 @@ import Terminal from './terminal'; -export default () => new Terminal({ selector: '#terminal' }); +export default () => new Terminal(document.getElementById('terminal')); diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index b24aa8a3a34..560f50ebf8f 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -1,9 +1,15 @@ +import _ from 'underscore'; import $ from 'jquery'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; + +const SCROLL_MARGIN = 5; + +Terminal.applyAddon(fit); export default class GLTerminal { - constructor(options = {}) { + constructor(element, options = {}) { this.options = Object.assign( {}, { @@ -13,7 +19,8 @@ export default class GLTerminal { options, ); - this.container = document.querySelector(options.selector); + this.container = element; + this.onDispose = []; this.setSocketUrl(); this.createTerminal(); @@ -34,8 +41,6 @@ export default class GLTerminal { } createTerminal() { - Terminal.applyAddon(fit); - this.terminal = new Terminal(this.options); this.socket = new WebSocket(this.socketUrl, ['terminal.gitlab.com']); @@ -72,4 +77,48 @@ export default class GLTerminal { handleSocketFailure() { this.terminal.write('\r\nConnection failure'); } + + addScrollListener(onScrollLimit) { + const viewport = this.container.querySelector('.xterm-viewport'); + const listener = _.throttle(() => { + onScrollLimit({ + canScrollUp: canScrollUp(viewport, SCROLL_MARGIN), + canScrollDown: canScrollDown(viewport, SCROLL_MARGIN), + }); + }); + + this.onDispose.push(() => viewport.removeEventListener('scroll', listener)); + viewport.addEventListener('scroll', listener); + + // don't forget to initialize value before scroll! + listener({ target: viewport }); + } + + disable() { + this.terminal.setOption('cursorBlink', false); + this.terminal.setOption('theme', { foreground: '#707070' }); + this.terminal.setOption('disableStdin', true); + this.socket.close(); + } + + dispose() { + this.terminal.off('data'); + this.terminal.dispose(); + this.socket.close(); + + this.onDispose.forEach(fn => fn()); + this.onDispose.length = 0; + } + + scrollToTop() { + this.terminal.scrollToTop(); + } + + scrollToBottom() { + this.terminal.scrollToBottom(); + } + + fit() { + this.terminal.fit(); + } } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 950347d8863..2f2a37347af 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -112,7 +112,7 @@ export default { </script> <template> - <div class="mr-widget-heading deploy-heading append-bottom-default"> + <div class="deploy-heading"> <div class="ci-widget media"> <div class="media-body"> <div class="deploy-body"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue new file mode 100644 index 00000000000..5967ca026e5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_container.vue @@ -0,0 +1,6 @@ +<template> + <div class="mr-widget-heading"> + <div class="mr-widget-content"><slot name="default"></slot></div> + <slot name="footer"></slot> + </div> +</template> 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 6f422ea3f27..3b9fc2661ef 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 @@ -6,6 +6,7 @@ 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 TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import MrWidgetIcon from './mr_widget_icon.vue'; export default { name: 'MRWidgetHeader', @@ -13,6 +14,7 @@ export default { Icon, clipboardButton, TooltipOnTruncate, + MrWidgetIcon, }, directives: { tooltip, @@ -76,7 +78,7 @@ export default { </script> <template> <div class="mr-source-target append-bottom-default"> - <div class="git-merge-icon-container append-right-default"><icon name="git-merge" /></div> + <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> <strong> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue new file mode 100644 index 00000000000..e3adc7f7af5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -0,0 +1,17 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { Icon }, + props: { + name: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="circle-icon-container append-right-default"><icon :name="name" /></div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 71571ba9cab..f11cf21b0ca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -79,67 +79,65 @@ export default { </script> <template> - <div v-if="hasPipeline || hasCIError" class="mr-widget-heading append-bottom-default"> - <div class="ci-widget media"> - <template v-if="hasCIError"> - <div - class="add-border ci-status-icon ci-status-icon-failed ci-error - js-ci-error append-right-default" - > - <icon :size="32" name="status_failed_borderless" /> - </div> - <div class="media-body" v-html="errorText"></div> - </template> - <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start append-right-default"> - <ci-icon :status="status" :size="32" :borderless="true" class="add-border" /> - </a> - <div class="ci-widget-container d-flex"> - <div class="ci-widget-content"> - <div class="media-body"> - <div class="font-weight-bold"> - Pipeline - <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" - >#{{ pipeline.id }}</a - > + <div v-if="hasPipeline || hasCIError" class="ci-widget media"> + <template v-if="hasCIError"> + <div + class="add-border ci-status-icon ci-status-icon-failed ci-error + js-ci-error append-right-default" + > + <icon :size="32" name="status_failed_borderless" /> + </div> + <div class="media-body" v-html="errorText"></div> + </template> + <template v-else-if="hasPipeline"> + <a :href="status.details_path" class="align-self-start append-right-default"> + <ci-icon :status="status" :size="32" :borderless="true" class="add-border" /> + </a> + <div class="ci-widget-container d-flex"> + <div class="ci-widget-content"> + <div class="media-body"> + <div class="font-weight-bold"> + Pipeline + <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" + >#{{ pipeline.id }}</a + > - {{ pipeline.details.status.label }} + {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> - for - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link font-weight-normal" - > - {{ pipeline.commit.short_id }}</a - > - on - <tooltip-on-truncate - :title="sourceBranch" - truncate-target="child" - class="label-branch label-truncate" - v-html="sourceBranchLink" - /> - </template> - </div> - <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div> + <template v-if="hasCommitInfo"> + for + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link font-weight-normal" + > + {{ pipeline.commit.short_id }}</a + > + on + <tooltip-on-truncate + :title="sourceBranch" + truncate-target="child" + class="label-branch label-truncate" + v-html="sourceBranchLink" + /> + </template> </div> + <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div> </div> - <div> - <span class="mr-widget-pipeline-graph"> - <span v-if="hasStages" class="stage-cell"> - <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" - > - <pipeline-stage :stage="stage" /> - </div> - </span> + </div> + <div> + <span class="mr-widget-pipeline-graph"> + <span v-if="hasStages" class="stage-cell"> + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> </span> - </div> + </span> </div> - </template> - </div> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue new file mode 100644 index 00000000000..5f5fe67b3c1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -0,0 +1,74 @@ +<script> +import Deployment from './deployment.vue'; +import MrWidgetContainer from './mr_widget_container.vue'; +import MrWidgetPipeline from './mr_widget_pipeline.vue'; + +/** + * Renders the pipeline and related deployments from the store. + * + * | Props | Description + * |---------------|------------- + * | `mr` | This is the mr_widget store + * | `isPostMerge` | If true, show the "post merge" pipeline and deployments + */ +export default { + name: 'MrWidgetPipelineContainer', + components: { + Deployment, + MrWidgetContainer, + MrWidgetPipeline, + }, + props: { + mr: { + type: Object, + required: true, + }, + isPostMerge: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + pipeline() { + return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; + }, + branch() { + return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch; + }, + branchLink() { + return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranchLink; + }, + deployments() { + return this.isPostMerge ? this.mr.postMergeDeployments : this.mr.deployments; + }, + deploymentClass() { + return this.isPostMerge ? 'js-post-deployment' : 'js-pre-deployment'; + }, + hasDeploymentMetrics() { + return this.isPostMerge; + }, + }, +}; +</script> +<template> + <mr-widget-container> + <mr-widget-pipeline + :pipeline="pipeline" + :ci-status="mr.ciStatus" + :has-ci="mr.hasCI" + :source-branch="branch" + :source-branch-link="branchLink" + :troubleshooting-docs-path="mr.troubleshootingDocsPath" + /> + <div v-if="deployments.length" slot="footer" class="mr-widget-extension"> + <deployment + v-for="deployment in deployments" + :key="deployment.id" + :class="deploymentClass" + :deployment="deployment" + :show-metrics="hasDeploymentMetrics" + /> + </div> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index a269c0a4e87..3c3e3efcc36 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -6,7 +6,7 @@ import SmartInterval from '~/smart_interval'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; -import WidgetPipeline from './components/mr_widget_pipeline.vue'; +import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; import Deployment from './components/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MergedState from './components/states/mr_widget_merged.vue'; @@ -44,7 +44,7 @@ export default { components: { 'mr-widget-header': WidgetHeader, 'mr-widget-merge-help': WidgetMergeHelp, - 'mr-widget-pipeline': WidgetPipeline, + MrWidgetPipelineContainer, Deployment, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, @@ -296,23 +296,12 @@ export default { <template> <div class="mr-state-widget prepend-top-default"> <mr-widget-header :mr="mr" /> - <mr-widget-pipeline + <mr-widget-pipeline-container v-if="shouldRenderPipelines" - :pipeline="mr.pipeline" - :ci-status="mr.ciStatus" - :has-ci="mr.hasCI" - :source-branch="mr.sourceBranch" - :source-branch-link="mr.sourceBranchLink" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" + class="mr-widget-workflow" + :mr="mr" /> - <deployment - v-for="deployment in mr.deployments" - :key="`pre-merge-deploy-${deployment.id}`" - class="js-pre-merge-deploy" - :deployment="deployment" - :show-metrics="false" - /> - <div class="mr-section-container"> + <div class="mr-section-container mr-widget-workflow"> <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" @@ -336,24 +325,11 @@ export default { </div> <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div> </div> - - <template v-if="shouldRenderMergedPipeline"> - <mr-widget-pipeline - class="js-post-merge-pipeline prepend-top-default" - :pipeline="mr.mergePipeline" - :ci-status="mr.ciStatus" - :has-ci="mr.hasCI" - :source-branch="mr.targetBranch" - :source-branch-link="mr.targetBranch" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" - /> - <deployment - v-for="postMergeDeployment in mr.postMergeDeployments" - :key="`post-merge-deploy-${postMergeDeployment.id}`" - :deployment="postMergeDeployment" - :show-metrics="true" - class="js-post-deployment" - /> - </template> + <mr-widget-pipeline-container + v-if="shouldRenderMergedPipeline" + class="js-post-merge-pipeline mr-widget-workflow" + :mr="mr" + :is-post-merge="true" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index bb2e0e12c11..75c66ed850b 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -1,7 +1,10 @@ <script> +import { diffModes } from '~/ide/constants'; import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; import ImageDiffViewer from './viewers/image_diff_viewer.vue'; import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; +import RenamedFile from './viewers/renamed.vue'; +import ModeChanged from './viewers/mode_changed.vue'; export default { props: { @@ -30,9 +33,25 @@ export default { required: false, default: '', }, + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, }, computed: { viewer() { + if (this.diffMode === diffModes.renamed) { + return RenamedFile; + } else if (this.diffMode === diffModes.mode_changed) { + return ModeChanged; + } + if (!this.newPath) return null; const previewInfo = viewerInformationForPath(this.newPath); @@ -67,8 +86,10 @@ export default { :new-path="fullNewPath" :old-path="fullOldPath" :project-path="projectPath" + :a-mode="aMode" + :b-mode="bMode" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <slot slot="image-overlay" name="image-overlay"></slot> </component> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue new file mode 100644 index 00000000000..3c7a4ea6183 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue @@ -0,0 +1,30 @@ +<script> +import { sprintf, __ } from '~/locale'; + +export default { + props: { + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, + }, + computed: { + outputText() { + return sprintf(__('File mode changed from %{a_mode} to %{b_mode}'), { + a_mode: this.aMode, + b_mode: this.bMode, + }); + }, + }, +}; +</script> + +<template> + <div class="nothing-here-block">{{ outputText }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue new file mode 100644 index 00000000000..5c1ea59b471 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -0,0 +1,3 @@ +<template> + <div class="nothing-here-block">{{ __('File moved') }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index b1faebf409b..8d3a3009c55 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -17,12 +17,14 @@ * /> */ import { mapGetters } from 'vuex'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', components: { userAvatarLink, + TimelineEntryItem, }, props: { note: { @@ -37,30 +39,28 @@ export default { </script> <template> - <li class="note being-posted fade-in-half timeline-entry"> - <div class="timeline-entry-inner"> - <div class="timeline-icon"> - <user-avatar-link - :link-href="getUserData.path" - :img-src="getUserData.avatar_url" - :img-size="40" - /> - </div> - <div :class="{ discussion: !note.individual_note }" class="timeline-content"> - <div class="note-header"> - <div class="note-header-info"> - <a :href="getUserData.path"> - <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> - <span class="note-headline-light">@{{ getUserData.username }}</span> - </a> - </div> + <timeline-entry-item class="note being-posted fade-in-half"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="getUserData.path" + :img-src="getUserData.avatar_url" + :img-size="40" + /> + </div> + <div :class="{ discussion: !note.individual_note }" class="timeline-content"> + <div class="note-header"> + <div class="note-header-info"> + <a :href="getUserData.path"> + <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> + <span class="note-headline-light">@{{ getUserData.username }}</span> + </a> </div> - <div class="note-body"> - <div class="note-text"> - <p>{{ note.body }}</p> - </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>{{ note.body }}</p> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue index 674f923478d..7689425eb52 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue @@ -1,4 +1,6 @@ <script> +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + /** * Common component to render a placeholder system note. * @@ -9,6 +11,9 @@ */ export default { name: 'PlaceholderSystemNote', + components: { + TimelineEntryItem, + }, props: { note: { type: Object, @@ -19,11 +24,9 @@ export default { </script> <template> - <li class="note system-note timeline-entry being-posted fade-in-half"> - <div class="timeline-entry-inner"> - <div class="timeline-content"> - <em>{{ note.body }}</em> - </div> + <timeline-entry-item class="note system-note being-posted fade-in-half"> + <div class="timeline-content"> + <em>{{ note.body }}</em> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index c6cf4661222..e61d1fd2031 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -1,22 +1,22 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; export default { name: 'SkeletonNote', components: { GlSkeletonLoading, + TimelineEntryItem, }, }; </script> <template> - <li class="timeline-entry note note-wrapper"> - <div class="timeline-entry-inner"> - <div class="timeline-icon"></div> - <div class="timeline-content"> - <div class="note-header"></div> - <div class="note-body"><gl-skeleton-loading /></div> - </div> + <timeline-entry-item class="note note-wrapper"> + <div class="timeline-icon"></div> + <div class="timeline-content"> + <div class="note-header"></div> + <div class="note-body"><gl-skeleton-loading /></div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index fb86262d0b4..31df26f7b05 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -20,6 +20,7 @@ import $ from 'jquery'; import { mapGetters } from 'vuex'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -29,6 +30,7 @@ export default { components: { Icon, noteHeader, + TimelineEntryItem, }, props: { note: { @@ -73,36 +75,34 @@ export default { </script> <template> - <li + <timeline-entry-item :id="noteAnchorId" :class="{ target: isTargetNote }" - class="note system-note timeline-entry note-wrapper" + class="note system-note note-wrapper" > - <div class="timeline-entry-inner"> - <div class="timeline-icon" v-html="iconHtml"></div> - <div class="timeline-content"> - <div class="note-header"> - <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> - <span v-html="actionTextHtml"></span> - </note-header> - </div> - <div class="note-body"> - <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" - class="note-text" - v-html="note.note_html" - ></div> - <div v-if="hasMoreCommits" class="flex-list"> - <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;"> - <icon :name="toggleIcon" :size="8" class="append-right-5" /> - <span>Toggle commit list</span> - </div> + <div class="timeline-icon" v-html="iconHtml"></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> + <span v-html="actionTextHtml"></span> + </note-header> + </div> + <div class="note-body"> + <div + :class="{ + 'system-note-commit-list': hasMoreCommits, + 'hide-shade': expanded, + }" + class="note-text" + v-html="note.note_html" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;"> + <icon :name="toggleIcon" :size="8" class="append-right-5" /> + <span>Toggle commit list</span> </div> </div> </div> </div> - </li> + </timeline-entry-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue new file mode 100644 index 00000000000..06974a12aed --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue @@ -0,0 +1,11 @@ +<script> +export default { + name: 'TimelineEntryItem', +}; +</script> + +<template> + <li class="timeline-entry"> + <div class="timeline-entry-inner"><slot></slot></div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index e742900dbcb..373794fb1f2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -44,6 +44,7 @@ export default { class="sidebar-collapsed-icon" data-placement="left" data-container="body" + data-boundary="viewport" @click="handleClick" > <i aria-hidden="true" data-hidden="true" class="fa fa-tags"> </i> diff --git a/app/assets/stylesheets/bootstrap.scss b/app/assets/stylesheets/bootstrap.scss index a040c2f8c20..4a09da3d580 100644 --- a/app/assets/stylesheets/bootstrap.scss +++ b/app/assets/stylesheets/bootstrap.scss @@ -1,5 +1,5 @@ /* - * Includes specific styles from the bootstrap4 foler in node_modules + * Includes specific styles from the bootstrap4 folder in node_modules */ @import "../../../node_modules/bootstrap/scss/functions"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 1e00aa4ff7e..62024b8c555 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -336,3 +336,12 @@ input[type=color].form-control { .input-group-btn:last-child { @extend .input-group-append; } + +/* + Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons, + so we need to reset the vertical alignment to the default value. See: + - https://gitlab.com/gitlab-org/gitlab-ce/issues/51362 + */ +svg { + vertical-align: baseline; +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 4041f2b4479..834e7ffce81 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -65,3 +65,4 @@ @import 'framework/feature_highlight'; @import 'framework/terms'; @import 'framework/read_more'; +@import 'framework/flex_grid'; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index fcf282a7d7c..054c75912ea 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -21,6 +21,7 @@ &.s46 { @include avatar-size(46px, 15px); } &.s48 { @include avatar-size(48px, 10px); } &.s60 { @include avatar-size(60px, 12px); } + &.s64 { @include avatar-size(64px, 14px); } &.s70 { @include avatar-size(70px, 14px); } &.s90 { @include avatar-size(90px, 15px); } &.s100 { @include avatar-size(100px, 15px); } @@ -80,6 +81,7 @@ &.s40 { font-size: 16px; line-height: 38px; } &.s48 { font-size: 20px; line-height: 46px; } &.s60 { font-size: 32px; line-height: 58px; } + &.s64 { font-size: 32px; line-height: 64px; } &.s70 { font-size: 34px; line-height: 70px; } &.s90 { font-size: 36px; line-height: 88px; } &.s100 { font-size: 36px; line-height: 98px; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 219fd99b097..e36f99ac577 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -142,8 +142,14 @@ &.btn-sm { padding: 4px 10px; - font-size: 13px; - line-height: 18px; + font-size: $gl-btn-small-font-size; + line-height: $gl-btn-small-line-height; + } + + &.btn-xs { + padding: 2px $gl-btn-padding; + font-size: $gl-btn-small-font-size; + line-height: $gl-btn-small-line-height; } &.btn-success, diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index bdd7f09d926..0d8e4afa76f 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -33,7 +33,11 @@ .bs-callout-warning { background-color: $orange-100; border-color: $orange-200; - color: $orange-700; + color: $orange-900; + + a { + color: $orange-900; + } } .bs-callout-info { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 626c8f92d1d..f2f3a45ca09 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -386,3 +386,4 @@ img.emoji { .flex-no-shrink { flex-shrink: 0; } .mw-460 { max-width: 460px; } .ws-initial { white-space: initial; } +.min-height-0 { min-height: 0; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 6f103e4e89a..8b6a7017c47 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -261,7 +261,7 @@ height: 1px; margin: 4px -1px; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } > .active { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce5d36a340f..f3c44f32d6f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -294,10 +294,10 @@ height: 1px; margin: #{$grid-size / 2} 0; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; &:hover { - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } } @@ -306,7 +306,7 @@ height: 1px; margin-top: 8px; margin-bottom: 8px; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } .dropdown-menu-empty-item a { @@ -542,7 +542,7 @@ text-align: center; text-overflow: ellipsis; white-space: nowrap; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; overflow: hidden; } @@ -621,7 +621,7 @@ padding: 0 7px; color: $gl-gray-700; line-height: 30px; - border: 1px solid $dropdown-divider-color; + border: 1px solid $dropdown-divider-bg; border-radius: 2px; outline: 0; @@ -656,7 +656,7 @@ padding-top: 10px; margin-top: 10px; font-size: 13px; - border-top: 1px solid $dropdown-divider-color; + border-top: 1px solid $dropdown-divider-bg; } .dropdown-footer-content { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index d5693a5d1a1..f48b3ddc912 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -363,6 +363,12 @@ background-color: $white-light; border-top: 0; } + + .filter-dropdown-container { + .dropdown { + margin-left: 0; + } + } } @include media-breakpoint-down(sm) { @@ -372,16 +378,6 @@ .dropdown-menu { width: 100%; } - - .dropdown { - margin-left: 0; - } - - .fa-chevron-down { - position: absolute; - right: 10px; - top: 10px; - } } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 7a4c3914fb0..afa85f0e4ae 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -32,16 +32,16 @@ margin: 0; } + .flash-text, + .flash-action { + display: inline-block; + } + .flash-alert { @extend .alert; background-color: $red-500; margin: 0; - .flash-text, - .flash-action { - display: inline-block; - } - .flash-action { margin-left: 5px; text-decoration: none; diff --git a/app/assets/stylesheets/framework/flex_grid.scss b/app/assets/stylesheets/framework/flex_grid.scss new file mode 100644 index 00000000000..10537fd5549 --- /dev/null +++ b/app/assets/stylesheets/framework/flex_grid.scss @@ -0,0 +1,52 @@ +.flex-grid { + .grid-row { + border-bottom: 1px solid $border-color; + padding: 0; + + &:last-child { + border-bottom: 0; + } + + @include media-breakpoint-down(md) { + border-bottom: 0; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + } + + @include media-breakpoint-down(xs) { + border-right: 0; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: 0; + } + } + } + + .grid-cell { + padding: 10px $gl-padding; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + + @include media-breakpoint-up(md) { + flex: 1; + } + + @include media-breakpoint-down(md) { + border-right: 0; + flex: none; + } + } +} + +.card { + .card-body.flex-grid { + padding: 0; + } +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 39410ac56af..c0cda29e239 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -383,6 +383,16 @@ top: 1px; } } + + .dropdown-menu li a .identicon { + width: 17px; + height: 17px; + font-size: $gl-font-size-xs; + vertical-align: middle; + text-indent: 0; + line-height: $gl-font-size-xs + 2px; + display: inline-block; + } } .breadcrumbs-list { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 452e946f95f..73533571a2f 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -42,11 +42,12 @@ padding: 10px; text-align: right; float: left; + line-height: 1; a { font-family: $monospace-font; display: block; - font-size: $code_font_size !important; + font-size: $code-font-size !important; min-height: 19px; white-space: nowrap; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index abd26e38d18..8db7d63266e 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -80,3 +80,15 @@ .user-avatar-link { text-decoration: none; } + +.circle-icon-container { + $border-size: 1px; + + display: flex; + align-items: center; + justify-content: center; + border: $border-size solid $theme-gray-400; + border-radius: 50%; + padding: $gl-padding-8 - $border-size; + color: $theme-gray-700; +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 6d20c46b99d..3bb046d0e51 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -39,15 +39,6 @@ .git-clone-holder { display: none; } - - // Display Star and Fork buttons without counters on mobile. - .project-repo-buttons { - display: block; - - .count-buttons .count-badge { - margin-top: $gl-padding-8; - } - } } .group-buttons { diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index de9e7c37695..19640ab5986 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -158,6 +158,10 @@ width: 100%; } + .dropdown-menu-toggle { + margin-bottom: 0; + } + form { display: block; height: auto; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 4a311da1675..3d5208c3db5 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -31,16 +31,6 @@ .timeline-entry-inner { position: relative; - - @include notes-media('max', map-get($grid-breakpoints, sm)) { - .timeline-icon { - display: none; - } - - .timeline-content { - margin-left: 0; - } - } } &:target, diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b43bb3feef5..134b3a4521b 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -197,6 +197,7 @@ $well-light-text-color: #5b6169; $gl-font-size: 14px; $gl-font-size-xs: 11px; $gl-font-size-small: 12px; +$gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; $gl-text-color: #2e2e2e; @@ -243,6 +244,7 @@ $gl-padding-top: 10px; $gl-sidebar-padding: 22px; $gl-bar-padding: 3px; $input-horizontal-padding: 12px; +$browserScrollbarSize: 10px; /* * Misc @@ -269,7 +271,8 @@ $performance-bar-height: 35px; $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; -$project-title-row-height: 24px; +$project-title-row-height: 64px; +$project-avatar-mobile-size: 24px; $gl-line-height: 16px; $gl-line-height-24: 24px; @@ -331,7 +334,6 @@ $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); -$dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-shadow: rgba($blue-300, 0.4); @@ -365,6 +367,8 @@ $gl-btn-padding: 10px; $gl-btn-line-height: 16px; $gl-btn-vert-padding: 8px; $gl-btn-horz-padding: 12px; +$gl-btn-small-font-size: 13px; +$gl-btn-small-line-height: 13px; /* * Badges diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 759b4f333ca..fab1b361f14 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -19,3 +19,5 @@ $info: $blue-500; $warning: $orange-500; $danger: $red-500; $zindex-modal-backdrop: 1040; +$nav-divider-margin-y: ($grid-size / 2); +$dropdown-divider-bg: $theme-gray-200; diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss new file mode 100644 index 00000000000..896a3466cb4 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss @@ -0,0 +1,18 @@ +@mixin ide-trace-view { + display: flex; + flex-direction: column; + height: 100%; + margin-top: -$grid-size; + margin-bottom: -$grid-size; + + &.build-page .top-bar { + top: 0; + height: auto; + font-size: 12px; + border-top-right-radius: $border-radius-default; + } + + .top-bar { + margin-left: -$gl-padding; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 07d82e984ba..98d0a2d43ea 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1,5 +1,6 @@ @import 'framework/variables'; @import 'framework/mixins'; +@import './ide_mixins'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -1111,11 +1112,7 @@ $ide-commit-header-height: 48px; } .ide-pipeline { - display: flex; - flex-direction: column; - height: 100%; - margin-top: -$grid-size; - margin-bottom: -$grid-size; + @include ide-trace-view(); .empty-state { margin-top: auto; @@ -1133,17 +1130,9 @@ $ide-commit-header-height: 48px; } } - .build-trace, - .top-bar { + .build-trace { margin-left: -$gl-padding; } - - &.build-page .top-bar { - top: 0; - height: auto; - font-size: 12px; - border-top-right-radius: $border-radius-default; - } } .ide-pipeline-list { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index c6074eb9df4..37984a8666f 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -41,7 +41,7 @@ .issue-board-dropdown-content { margin: 0 8px 10px; padding-bottom: 10px; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; > p { margin: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8ea34f5d19d..bb6b6f84849 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -259,6 +259,16 @@ ul.related-merge-requests > li { display: block; } +.issue-sort-dropdown { + .btn-group { + width: 100%; + } + + .reverse-sort-btn { + color: $gl-text-color-secondary; + } +} + @include media-breakpoint-up(sm) { .emoji-block .row { display: flex; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index b075009b57c..221b4e934ff 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -50,9 +50,19 @@ .mr-widget-heading { position: relative; border: 1px solid $border-color; - border-radius: 4px; + border-radius: $border-radius-default; +} - &:not(.deploy-heading)::before { +.mr-widget-extension { + border-top: 1px solid $border-color; + background-color: $gray-light; +} + +.mr-widget-workflow { + margin-top: $gl-padding; + position: relative; + + &::before { content: ''; border-left: 1px solid $theme-gray-200; position: absolute; @@ -68,8 +78,8 @@ border-top: 0; } -.mr-widget-heading, .mr-widget-section, +.mr-widget-content, .mr-widget-footer { padding: $gl-padding; } @@ -560,19 +570,6 @@ color: $gl-text-color; } - .git-merge-icon-container { - border: 1px solid $theme-gray-400; - border-radius: 50%; - height: 32px; - width: 32px; - color: $theme-gray-700; - line-height: 28px; - - .ic-git-merge { - vertical-align: middle; - width: 31px; - } - } .git-merge-container { justify-content: space-between; @@ -854,11 +851,6 @@ } .deploy-heading { - margin-top: -19px; - border-top-left-radius: 0; - border-top-right-radius: 0; - background-color: $gray-light; - @include media-breakpoint-up(md) { padding: $gl-padding-8 $gl-padding; } @@ -868,6 +860,10 @@ font-size: 12px; margin-left: 48px; } + + &:not(:last-child) { + border-bottom: 1px solid $border-color; + } } .deploy-body { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 4fda2964fd5..39d01c49fd7 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -371,10 +371,10 @@ $note-form-margin-left: 72px; &::after { content: ''; - width: 100%; height: 70px; position: absolute; - left: 0; + left: $gl-padding-24; + right: 0; bottom: 0; background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%); } @@ -589,12 +589,6 @@ $note-form-margin-left: 72px; padding-bottom: 0; } -.note-header-author-name { - @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { - display: none; - } -} - .note-headline-light { display: inline; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 1d691d1d8b8..132f3fea92b 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -144,11 +144,13 @@ .provider-btn-group { display: inline-block; margin-right: 10px; + margin-bottom: 10px; border: 1px solid $border-color; border-radius: 3px; &:last-child { margin-right: 0; + margin-bottom: 0; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 80ec390d18e..278800aba95 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -144,7 +144,6 @@ .group-home-panel { padding-top: 24px; padding-bottom: 24px; - border-bottom: 1px solid $border-color; .group-avatar { float: none; @@ -155,7 +154,6 @@ } } - .project-title, .group-title { margin-top: 10px; margin-bottom: 10px; @@ -195,25 +193,69 @@ } .project-home-panel { - padding-top: $gl-padding-8; - padding-bottom: $gl-padding-24; - - .project-title-row { - margin-right: $gl-padding-8; - } + padding-top: $gl-padding; + padding-bottom: $gl-padding; .project-avatar { width: $project-title-row-height; height: $project-title-row-height; flex-shrink: 0; flex-basis: $project-title-row-height; - margin: 0 $gl-padding-8 0 0; + margin: 0 $gl-padding 0 0; } .project-title { + margin-top: 8px; + margin-bottom: 5px; font-size: 20px; - line-height: $project-title-row-height; + line-height: $gl-line-height-24; font-weight: bold; + + .icon { + font-size: $gl-font-size-large; + } + + .project-visibility { + color: $gl-text-color-secondary; + } + + .project-tag-list { + font-size: $gl-font-size; + font-weight: $gl-font-weight-normal; + + .icon { + position: relative; + top: 3px; + margin-right: $gl-padding-4; + } + } + } + + .project-title-row { + @include media-breakpoint-down(sm) { + .project-avatar { + width: $project-avatar-mobile-size; + height: $project-avatar-mobile-size; + flex-basis: $project-avatar-mobile-size; + + .avatar { + font-size: 20px; + line-height: 46px; + } + } + + .project-title { + margin-top: 4px; + margin-bottom: 2px; + font-size: $gl-font-size; + line-height: $gl-font-size-large; + } + + .project-tag-list, + .project-metadata { + font-size: $gl-font-size-small; + } + } } .project-metadata { @@ -222,16 +264,6 @@ line-height: $gl-btn-line-height; color: $gl-text-color-secondary; - .icon { - margin-right: $gl-padding-4; - font-size: 16px; - } - - .project-visibility, - .project-license, - .project-tag-list { - margin-right: $gl-padding-8; - } .project-license { .btn { @@ -240,12 +272,22 @@ } } - .project-tag-list, - .project-license { - .icon { - position: relative; - top: 2px; - } + .access-request-link, + .project-tag-list { + padding-left: $gl-padding-8; + border-left: 1px solid $gl-text-color-secondary; + } + } + + .project-description { + @include media-breakpoint-up(md) { + font-size: $gl-font-size-large; + } + } + + .notifications-btn { + .fa-bell { + margin-right: 0; } } } @@ -298,14 +340,6 @@ vertical-align: top; margin-top: $gl-padding; - .count-badge { - height: $input-height; - - .icon { - top: -1px; - } - } - .count-badge-count, .count-badge-button { border: 1px solid $border-color; @@ -319,29 +353,25 @@ .count-badge-count { padding: 0 12px; - border-right: 0; - border-radius: $border-radius-base 0 0 $border-radius-base; background: $gray-light; + border-radius: 0 $border-radius-base $border-radius-base 0; } .count-badge-button { - border-radius: 0 $border-radius-base $border-radius-base 0; + border-right: 0; + border-radius: $border-radius-base 0 0 $border-radius-base; } } .project-clone-holder { display: inline-block; - margin: $gl-padding $gl-padding-8 0 0; + margin: $gl-padding 0 0; input { height: $input-height; } } - .clone-dropdown-btn { - background-color: $white-light; - } - .clone-options-dropdown { min-width: 240px; @@ -355,6 +385,31 @@ } } +.project-repo-buttons { + .icon { + top: 0; + } + + .count-badge, + .btn-xs { + height: 24px; + } + + .dropdown-toggle, + .clone-dropdown-btn { + .fa { + color: unset; + } + } + + .btn { + .notifications-icon { + top: 1px; + margin-right: 0; + } + } +} + .split-one { display: inline-table; margin-right: 12px; @@ -715,15 +770,16 @@ border-bottom: 1px solid $border-color; } -.project-stats { +.project-stats, +.project-buttons { font-size: 0; text-align: center; - border-bottom: 1px solid $border-color; .scrolling-tabs-container { .scrolling-tabs { margin-top: $gl-padding-8; - margin-bottom: $gl-padding-8; + margin-bottom: $gl-padding-8 - $browserScrollbarSize; + padding-bottom: $browserScrollbarSize; flex-wrap: wrap; border-bottom: 0; } @@ -731,7 +787,7 @@ .fade-left, .fade-right { top: 0; - height: 100%; + height: calc(100% - #{$browserScrollbarSize}); .fa { top: 50%; @@ -785,23 +841,43 @@ font-size: $gl-font-size; line-height: $gl-btn-line-height; color: $gl-text-color-secondary; - white-space: nowrap; + white-space: pre-wrap; } .stat-link { border-bottom: 0; + color: $black; &:hover, &:focus { - color: $gl-text-color; text-decoration: underline; border-bottom: 0; } + + .project-stat-value { + color: $gl-text-color; + } + + .icon { + color: $gl-text-color-secondary; + } + + .add-license-link { + &, + .icon { + color: $blue-600; + } + } } .btn { - padding: $gl-btn-vert-padding $gl-btn-horz-padding; + margin-top: $gl-padding; + padding: $gl-btn-vert-padding $gl-btn-padding; line-height: $gl-btn-line-height; + + .icon { + top: 0; + } } .btn-missing { @@ -810,6 +886,13 @@ } } +.project-buttons { + .stat-text { + @extend .btn; + @extend .btn-default; + } +} + .repository-languages-bar { height: 8px; margin-bottom: $gl-padding-8; @@ -933,8 +1016,6 @@ pre.light-well { } .git-clone-holder { - width: 320px; - .btn-clipboard { border: 1px solid $border-color; } @@ -957,6 +1038,15 @@ pre.light-well { } } +.git-clone-holder, +.mobile-git-clone { + .btn { + .icon { + fill: $white; + } + } +} + .cannot-be-merged, .cannot-be-merged:hover { color: $red-500; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index dc5ca78ff58..a46b8679a42 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -104,11 +104,23 @@ border-bottom: 1px solid $white-normal; border-top: 1px solid $white-normal; + &:last-of-type { + border-bottom-color: $white-light; + } + td, th { line-height: 21px; } + th { + border-top-color: $gray-light; + } + + td { + border-color: $border-color; + } + &:hover:not(.tree-truncated-warning) { td { background-color: $blue-50; diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 08d7e3b4fa2..65fe22bd8f4 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -5,23 +5,12 @@ class Admin::ImpersonationsController < Admin::ApplicationController before_action :authenticate_impersonator! def destroy - original_user = current_user - - warden.set_user(impersonator, scope: :user) - - Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}") - - session[:impersonator_id] = nil - + original_user = stop_impersonation redirect_to admin_user_path(original_user), status: :found end private - def impersonator - @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] - end - def authenticate_impersonator! render_404 unless impersonator && impersonator.admin? && !impersonator.blocked? end diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb index 64d74ae4231..57f7d3e3951 100644 --- a/app/controllers/admin/requests_profiles_controller.rb +++ b/app/controllers/admin/requests_profiles_controller.rb @@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController profile = Gitlab::RequestProfiler::Profile.find(clean_name) if profile - render text: profile.content + render html: profile.content else redirect_to admin_requests_profiles_path, alert: 'Profile not found' end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b783c0e2a6f..e93be1c1ba2 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -2,6 +2,7 @@ class Admin::UsersController < Admin::ApplicationController before_action :user, except: [:index, :new, :create] + before_action :check_impersonation_availability, only: :impersonate def index @users = User.order_name_asc.filter(params[:filter]) @@ -227,4 +228,8 @@ class Admin::UsersController < Admin::ApplicationController result[:status] == :success end + + def check_impersonation_availability + access_denied! unless Gitlab.config.gitlab.impersonation_enabled + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9b40ffb26a2..7c8c1392c1c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,15 +8,14 @@ class ApplicationController < ActionController::Base include GitlabRoutingHelper include PageLayoutHelper include SafeParamsHelper - include SentryHelper include WorkhorseHelper include EnforcesTwoFactorAuthentication include WithPerformanceBar + include SessionlessAuthentication # this can be removed after switching to rails 5 # https://gitlab.com/gitlab-org/gitlab-ce/issues/51908 include InvalidUTF8ErrorHandler unless Gitlab.rails5? - before_action :authenticate_sessionless_user! before_action :authenticate_user! before_action :enforce_terms!, if: :should_enforce_terms? before_action :validate_user_service_ticket! @@ -28,6 +27,7 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? before_action :set_usage_stats_consent_flag + before_action :check_impersonation_availability around_action :set_locale @@ -128,6 +128,7 @@ class ApplicationController < ActionController::Base payload[:ua] = request.env["HTTP_USER_AGENT"] payload[:remote_ip] = request.remote_ip + payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id logged_user = auth_user @@ -153,15 +154,8 @@ class ApplicationController < ActionController::Base end end - # This filter handles personal access tokens, and atom requests with rss tokens - def authenticate_sessionless_user! - user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user - - sessionless_sign_in(user) if user - end - def log_exception(exception) - Raven.capture_exception(exception) if sentry_enabled? + Gitlab::Sentry.track_acceptable_exception(exception) backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace @@ -426,25 +420,11 @@ class ApplicationController < ActionController::Base Gitlab::I18n.with_user_locale(current_user, &block) end - def sessionless_sign_in(user) - if user && can?(user, :log_in) - # Notice we are passing store false, so the user is not - # actually stored in the session and a token is needed - # for every request. If you want the token to work as a - # sign in token, you can simply remove store: false. - sign_in(user, store: false, message: :sessionless_sign_in) - end - end - def set_page_title_header # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 response.headers['Page-Title'] = URI.escape(page_title('GitLab')) end - def sessionless_user? - current_user && !session.keys.include?('warden.user.user.key') - end - def peek_request? request.path.start_with?('/-/peek') end @@ -483,4 +463,32 @@ class ApplicationController < ActionController::Base .new(settings, current_user, application_setting_params) .execute end + + def check_impersonation_availability + return unless session[:impersonator_id] + + unless Gitlab.config.gitlab.impersonation_enabled + stop_impersonation + access_denied! _('Impersonation has been disabled') + end + end + + def stop_impersonation + impersonated_user = current_user + + Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{impersonated_user.username}") + + warden.set_user(impersonator, scope: :user) + session[:impersonator_id] = nil + + impersonated_user + end + + def impersonator + @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] + end + + def sentry_context + Gitlab::Sentry.context(current_user) + end end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 7f874687212..0dd7500623d 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -100,18 +100,12 @@ module Boards .merge(board_id: params[:board_id], list_id: params[:list_id], request: request) end + def serializer + IssueSerializer.new(current_user: current_user) + end + def serialize_as_json(resource) - resource.as_json( - only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position, :weight], - labels: true, - issue_endpoints: true, - include_full_project_path: board.group_board?, - include: { - project: { only: [:id, :path] }, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - milestone: { only: [:id, :title] } - } - ) + serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) end def whitelist_query_limiting diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index b4f46cddbe9..8d518c14b90 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -15,7 +15,7 @@ class ChaosController < ActionController::Base duration_taken = (Time.now - start).seconds Kernel.sleep duration_s - duration_taken if duration_s > duration_taken - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def cpuspin @@ -24,14 +24,14 @@ class ChaosController < ActionController::Base rand while Time.now < end_time - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def sleep duration_s = (params[:duration_s]&.to_i || 30).seconds Kernel.sleep duration_s - render text: "OK", content_type: 'text/plain' + render plain: "OK" end def kill @@ -44,13 +44,13 @@ class ChaosController < ActionController::Base secret = ENV['GITLAB_CHAOS_SECRET'] # GITLAB_CHAOS_SECRET is required unless you're running in Development mode if !secret && !Rails.env.development? - render text: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", content_type: 'text/plain', status: 500 + render plain: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET when using GITLAB_ENABLE_CHAOS_ENDPOINTS outside of a development environment", status: :internal_server_error end return unless secret unless request.headers["HTTP_X_CHAOS_SECRET"] == secret - render text: "To experience chaos, please set X-Chaos-Secret header", content_type: 'text/plain', status: 401 + render plain: "To experience chaos, please set X-Chaos-Secret header", status: :unauthorized end end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 34a8c50fcbd..a597996a362 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -91,7 +91,7 @@ module IssuableCollections options = { scope: params[:scope], state: params[:state], - sort: set_sort_order_from_cookie || default_sort_order + sort: set_sort_order } # Used by view to highlight active option @@ -102,7 +102,7 @@ module IssuableCollections elsif @group options[:group_id] = @group.id options[:include_subgroups] = true - options[:use_cte_for_search] = true + options[:attempt_group_search_optimizations] = true end params.permit(finder_type.valid_params).merge(options) @@ -113,6 +113,32 @@ module IssuableCollections 'opened' end + def set_sort_order + set_sort_order_from_user_preference || set_sort_order_from_cookie || default_sort_order + end + + def set_sort_order_from_user_preference + return unless current_user + return unless issuable_sorting_field + + user_preference = current_user.user_preference + + sort_param = params[:sort] + sort_param ||= user_preference[issuable_sorting_field] + + if user_preference[issuable_sorting_field] != sort_param + user_preference.update_attribute(issuable_sorting_field, sort_param) + end + + sort_param + end + + # Implement default_sorting_field method on controllers + # to choose which column to store the sorting parameter. + def issuable_sorting_field + nil + end + def set_sort_order_from_cookie sort_param = params[:sort] if params[:sort].present? # fallback to legacy cookie value for backward compatibility @@ -141,12 +167,6 @@ module IssuableCollections case value when 'id_asc' then sort_value_oldest_created when 'id_desc' then sort_value_recently_created - when 'created_asc' then sort_value_created_date - when 'created_desc' then sort_value_created_date - when 'due_date_asc' then sort_value_due_date - when 'due_date_desc' then sort_value_due_date - when 'milestone_due_asc' then sort_value_milestone - when 'milestone_due_desc' then sort_value_milestone when 'downvotes_asc' then sort_value_popularity when 'downvotes_desc' then sort_value_popularity else value diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 777b147e2dd..0319948a12f 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -6,6 +6,7 @@ module NotesActions extend ActiveSupport::Concern included do + prepend_before_action :normalize_create_params, only: [:create] before_action :set_polling_interval_header, only: [:index] before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] @@ -247,6 +248,15 @@ module NotesActions DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity) end + # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for + # is the object we're actually creating a note in. + def normalize_create_params + params[:note].try do |note| + note[:noteable_id] = params[:target_id] + note[:noteable_type] = params[:target_type].classify + end + end + def note_project strong_memoize(:note_project) do next nil unless project diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb new file mode 100644 index 00000000000..590eefc6dab --- /dev/null +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# == SessionlessAuthentication +# +# Controller concern to handle PAT and RSS token authentication methods +# +module SessionlessAuthentication + # This filter handles personal access tokens, and atom requests with rss tokens + def authenticate_sessionless_user!(request_format) + user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format) + + sessionless_sign_in(user) if user + end + + def sessionless_user? + current_user && !session.keys.include?('warden.user.user.key') + end + + def sessionless_sign_in(user) + if user && can?(user, :log_in) + # Notice we are passing store false, so the user is not + # actually stored in the session and a token is needed + # for every request. If you want the token to work as a + # sign in token, you can simply remove store: false. + sign_in(user, store: false, message: :sessionless_sign_in) + end + end +end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 8c22490700c..014232a7d05 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -10,6 +10,8 @@ module SnippetsActions def raw disposition = params[:inline] == 'false' ? 'attachment' : 'inline' + workhorse_set_content_type! + send_data( convert_line_endings(@snippet.content), type: 'text/plain; charset=utf-8', diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 7a1c7abfb8f..0eea0cdd50f 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,17 +1,11 @@ # frozen_string_literal: true module UploadsActions - extend ActiveSupport::Concern - include Gitlab::Utils::StrongMemoize include SendFileUpload UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze - included do - prepend_before_action :set_html_format, only: :show - end - def create link_to_file = UploadService.new(model, params[:file], uploader_class).execute @@ -44,6 +38,7 @@ module UploadsActions return render_404 unless uploader + workhorse_set_content_type! send_upload(uploader, attachment: uploader.filename, disposition: disposition) end @@ -61,13 +56,6 @@ module UploadsActions private - # Explicitly set the format. - # Otherwise rails 5 will set it from a file extension. - # See https://github.com/rails/rails/commit/84e8accd6fb83031e4c27e44925d7596655285f7#diff-2b8f2fbb113b55ca8e16001c393da8f1 - def set_html_format - request.format = :html - end - def uploader_class raise NotImplementedError end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index e9686ed8d06..57e612d89d3 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -4,6 +4,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param before_action :default_sorting skip_cross_project_access_check :index, :starred diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index b82caf30a91..3fa582cf25b 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -4,6 +4,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController include ActionView::Helpers::NumberHelper before_action :authorize_read_project!, only: :index + before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] def index @@ -60,6 +61,15 @@ class Dashboard::TodosController < Dashboard::ApplicationController end end + def authorize_read_group! + group_id = params[:group_id] + + if group_id.present? + group = Group.find(group_id) + render_404 unless can?(current_user, :read_group, group) + end + end + def find_todos @todos ||= TodosFinder.new(current_user, todo_params).execute end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4ce9be44403..be2d9512c01 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -4,6 +4,9 @@ class DashboardController < Dashboard::ApplicationController include IssuesAction include MergeRequestsAction + prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } + before_action :event_filter, only: :activity before_action :projects, only: [:issues, :merge_requests] before_action :set_show_full_reference, only: [:issues, :merge_requests] diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index a1ec144410b..6ea4758ec32 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,6 +3,7 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! + prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } before_action :check_graphql_feature_flag! diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index 50c44b7a58b..b846fb21266 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -3,8 +3,8 @@ class Groups::ClustersController < Clusters::ClustersController include ControllerWithCrossProjectAccessCheck - prepend_before_action :check_group_clusters_feature_flag! prepend_before_action :group + prepend_before_action :check_group_clusters_feature_flag! requires_cross_project_access layout 'group' @@ -20,6 +20,10 @@ class Groups::ClustersController < Clusters::ClustersController end def check_group_clusters_feature_flag! - render_404 unless Feature.enabled?(:group_clusters) + render_404 unless group_clusters_enabled? + end + + def group_clusters_enabled? + group.group_clusters_enabled? end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 062c8c4e9e1..c5d8ac2ed77 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -9,6 +9,9 @@ class GroupsController < Groups::ApplicationController respond_to :html + prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } + before_action :authenticate_user!, only: [:new, :create] before_action :group, except: [:index, :new, :create] diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 7353be478e1..c2089a0fca3 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -15,7 +15,7 @@ class MetricsController < ActionController::Base "# Metrics are disabled, see: #{help_page}\n" end - render text: response, content_type: 'text/plain; version=0.0.4' + render plain: response, content_type: 'text/plain; version=0.0.4' end private diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 84dce74ace8..384f308269a 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -16,7 +16,11 @@ class NotificationSettingsController < ApplicationController @notification_setting = current_user.notification_settings.find(params[:id]) @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) - render_response + if params[:hide_label].present? + render_response("projects/buttons/_notifications") + else + render_response + end end private @@ -37,9 +41,9 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response + def render_response(response_template = "shared/notifications/_button") render json: { - html: view_to_html_string("shared/notifications/_button", notification_setting: @notification_setting), + html: view_to_html_string(response_template, notification_setting: @notification_setting), saved: @saved } end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index b50f140dc80..ab4ca56bb49 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -9,7 +9,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController before_action :verify_user_oauth_applications_enabled, except: :index before_action :authenticate_user! before_action :add_gon_variables - before_action :load_scopes, only: [:index, :create, :edit] + before_action :load_scopes, only: [:index, :create, :edit, :update] helper_method :can? diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index cb3180f4196..b0d65f284af 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -4,7 +4,7 @@ class Profiles::AccountsController < Profiles::ApplicationController include AuthHelper def show - @user = current_user + render(locals: show_view_variables) end # rubocop: disable CodeReuse/ActiveRecord @@ -23,4 +23,10 @@ class Profiles::AccountsController < Profiles::ApplicationController redirect_to profile_account_path end # rubocop: enable CodeReuse/ActiveRecord + + private + + def show_view_variables + {} + end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 912421e3d08..dcee8eb7e6e 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -41,12 +41,12 @@ class Profiles::KeysController < Profiles::ApplicationController user = UserFinder.new(params[:username]).find_by_username if user.present? headers['Content-Disposition'] = 'attachment' - render text: user.all_ssh_keys.join("\n"), content_type: 'text/plain' + render plain: user.all_ssh_keys.join("\n") else return render_404 end rescue => e - render text: e.message + render html: e.message end else return render_404 diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index ae9c17802b9..1a91e07b97f 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -9,7 +9,6 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path - before_action :set_request_format, only: [:file] before_action :validate_artifacts!, except: [:download] before_action :entry, only: [:file] @@ -110,12 +109,4 @@ class Projects::ArtifactsController < Projects::ApplicationController render_404 unless @entry.exists? end - - def set_request_format - request.format = :html if set_request_format? - end - - def set_request_format? - request.format != :json - end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 873c96a5523..60fabd15333 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,7 +9,6 @@ class Projects::BlobController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper prepend_before_action :authenticate_user!, only: [:edit] - before_action :set_request_format, only: [:edit, :show, :update, :destroy] before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! @@ -242,18 +241,6 @@ class Projects::BlobController < Projects::ApplicationController .last_for_path(@repository, @ref, @path).sha end - # In Rails 4.2 if params[:format] is empty, Rails set it to :html - # But since Rails 5.0 the framework now looks for an extension. - # E.g. for `blob/master/CHANGELOG.md` in Rails 4 the format would be `:html`, but in Rails 5 on it'd be `:md` - # This before_action explicitly sets the `:html` format for all requests unless `:format` is set by a client e.g. by JS for XHR requests. - def set_request_format - request.format = :html if set_request_format? - end - - def set_request_format? - params[:id].present? && params[:format].blank? && request.format != "json" - end - def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 95a014d24da..a6bfb913900 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -22,7 +22,7 @@ class Projects::BranchesController < Projects::ApplicationController # Fetch branches for the specified mode fetch_branches_by_mode - @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) + @refs_pipelines = @project.ci_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/gitaly/issues/992 diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 84a2a461da7..e40a1a1d744 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -6,12 +6,12 @@ class Projects::CommitsController < Projects::ApplicationController include ExtractsPath include RendersCommits + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } before_action :whitelist_query_limiting, except: :commits_root before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root before_action :authorize_download_code! before_action :set_commits, except: :commits_root - before_action :set_request_format, only: :show def commits_root redirect_to project_commits_path(@project, @project.default_branch) @@ -70,19 +70,6 @@ class Projects::CommitsController < Projects::ApplicationController @commits = set_commits_for_rendering(@commits) end - # Rails 5 sets request.format from the extension. - # Explicitly set to :html. - def set_request_format - request.format = :html if set_request_format? - end - - # Rails 5 sets request.format from extension. - # In this case if the ref ends with `.atom`, it's expected to be the html response, - # not the atom one. So explicitly set request.format as :html to act like rails4. - def set_request_format? - request.format.to_s == "text/html" || @commits.ref.ends_with?("atom") - end - def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330') end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index de10783df1a..e940f382a19 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -122,7 +122,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController set_workhorse_internal_api_content_type render json: Gitlab::Workhorse.terminal_websocket(terminal) else - render text: 'Not found', status: :not_found + render html: 'Not found', status: :not_found end end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index e55065c5817..a10e159ea1e 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -13,10 +13,8 @@ class Projects::ImportsController < Projects::ApplicationController end def create - @project.import_url = params[:project][:import_url] - - if @project.save - @project.reload.import_schedule + if @project.update(safe_import_params) + @project.import_state.reload.schedule end redirect_to project_import_path(@project) @@ -24,7 +22,7 @@ class Projects::ImportsController < Projects::ApplicationController def show if @project.import_finished? - if continue_params + if continue_params&.key?(:to) redirect_to continue_params[:to], notice: continue_params[:notice] else redirect_to project_path(@project), notice: finished_notice @@ -67,4 +65,12 @@ class Projects::ImportsController < Projects::ApplicationController redirect_to project_path(@project) end end + + def import_params + params.require(:project).permit(:import_url) + end + + def safe_import_params + import_params + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d6d7110355b..c6ab6b4642e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -9,10 +9,6 @@ class Projects::IssuesController < Projects::ApplicationController include IssuesCalendar include SpammableActions - def self.authenticate_user_only_actions - %i[new] - end - def self.issue_except_actions %i[index calendar new create bulk_update] end @@ -21,7 +17,10 @@ class Projects::IssuesController < Projects::ApplicationController %i[index calendar] end - prepend_before_action :authenticate_user!, only: authenticate_user_only_actions + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } + prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } + prepend_before_action :authenticate_new_issue!, only: [:new] + prepend_before_action :store_uri, only: [:new, :show] before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] before_action :check_issues_available! @@ -232,16 +231,18 @@ class Projects::IssuesController < Projects::ApplicationController ] + [{ label_ids: [], assignee_ids: [] }] end - def authenticate_user! + def authenticate_new_issue! return if current_user notice = "Please sign in to create the new issue." + redirect_to new_user_session_path, notice: notice + end + + def store_uri if request.get? && !request.xhr? store_location_for :user, request.fullpath end - - redirect_to new_user_session_path, notice: notice end def serializer diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 3ecf94c008e..c58b30eace7 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -140,15 +140,22 @@ class Projects::JobsController < Projects::ApplicationController def raw if trace_artifact_file + workhorse_set_content_type! send_upload(trace_artifact_file, send_params: raw_send_params, redirect_params: raw_redirect_params) else build.trace.read do |stream| if stream.file? + workhorse_set_content_type! send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' else - send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log' + # In this case we can't use workhorse_set_content_type! and let + # Workhorse handle the response because the data is streamed directly + # to the user but, because we have the trace content, we can calculate + # the proper content type and disposition here. + raw_data = stream.raw + send_data raw_data, type: 'text/plain; charset=utf-8', disposition: raw_trace_content_disposition(raw_data), filename: 'job.log' end end end @@ -201,4 +208,13 @@ class Projects::JobsController < Projects::ApplicationController def build_path(build) project_job_path(build.project, build) end + + def raw_trace_content_disposition(raw_data) + mime_type = MimeMagic.by_magic(raw_data) + + # if mime_type is nil can also represent 'text/plain' + return 'inline' if mime_type.nil? || mime_type.type == 'text/plain' + + 'attachment' + end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index b3d77335c2a..ddffbb17ace 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -22,12 +22,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def render_diffs @environment = @merge_request.environments_for(current_user).last - notes_grouped_by_path = renderable_notes.group_by { |note| note.position.file_path } - @diffs.diff_files.each do |diff_file| - notes = notes_grouped_by_path.fetch(diff_file.file_path, []) - notes.each { |note| diff_file.unfold_diff_lines(note.position) } - end + note_positions = renderable_notes.map(&:position).compact + @diffs.unfold_diff_files(note_positions) @diffs.write_cache diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d521db79f85..da9316d5f22 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -122,17 +122,21 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo respond_to do |format| format.html do - if @merge_request.valid? - redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request]) - else + if @merge_request.errors.present? define_edit_vars render :edit + else + redirect_to project_merge_request_path(@merge_request.target_project, @merge_request) end end format.json do - render json: serializer.represent(@merge_request, serializer: 'basic') + if merge_request.errors.present? + render json: @merge_request.errors, status: :bad_request + else + render json: serializer.represent(@merge_request, serializer: 'basic') + end end end rescue ActiveRecord::StaleObjectError diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 20998c97730..8e68014a30d 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -11,7 +11,10 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + + # Allow to promote milestone + before_action :authorize_promote_milestone!, only: :promote respond_to :html @@ -78,7 +81,7 @@ class Projects::MilestonesController < Projects::ApplicationController def promote promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = flash_notice_for(promoted_milestone, project.group) + flash[:notice] = flash_notice_for(promoted_milestone, project_group) respond_to do |format| format.html do @@ -109,6 +112,12 @@ class Projects::MilestonesController < Projects::ApplicationController protected + def project_group + strong_memoize(:project_group) do + project.group + end + end + def milestones strong_memoize(:milestones) do MilestonesFinder.new(search_params).execute @@ -125,13 +134,17 @@ class Projects::MilestonesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_milestone, @project) end + def authorize_promote_milestone! + return render_404 unless can?(current_user, :admin_milestone, project_group) + end + def milestone_params params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) end def search_params - if request.format.json? && @project.group && can?(current_user, :read_group, @project.group) - groups = @project.group.self_and_ancestors_ids + if request.format.json? && project_group && can?(current_user, :read_group, project_group) + groups = project_group.self_and_ancestors_ids end params.permit(:state).merge(project_ids: @project.id, group_ids: groups) diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index ad2466a8588..6543711ecfa 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -8,6 +8,7 @@ class Projects::NetworkController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_download_code! + before_action :assign_options before_action :assign_commit def show @@ -29,10 +30,13 @@ class Projects::NetworkController < Projects::ApplicationController render end + def assign_options + @options = params.permit(:filter_ref, :extended_sha1) + end + def assign_commit - return if params[:extended_sha1].blank? + return if @options[:extended_sha1].blank? - @options[:extended_sha1] = params[:extended_sha1] @commit = @repo.commit(@options[:extended_sha1]) end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 53b29d4146e..67827b1d3bb 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -46,7 +46,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def new - @pipeline = project.pipelines.new(ref: @project.default_branch) + @pipeline = project.all_pipelines.new(ref: @project.default_branch) end def create @@ -142,9 +142,9 @@ class Projects::PipelinesController < Projects::ApplicationController @charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project) @counts = {} - @counts[:total] = @project.pipelines.count(:all) - @counts[:success] = @project.pipelines.success.count(:all) - @counts[:failed] = @project.pipelines.failed.count(:all) + @counts[:total] = @project.all_pipelines.count(:all) + @counts[:success] = @project.all_pipelines.success.count(:all) + @counts[:failed] = @project.all_pipelines.failed.count(:all) end private @@ -164,7 +164,7 @@ class Projects::PipelinesController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def pipeline @pipeline ||= project - .pipelines + .all_pipelines .includes(user: :status) .find_by!(id: params[:id]) .present(current_user: current_user) diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb new file mode 100644 index 00000000000..0af2b7ef343 --- /dev/null +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsController < Projects::ApplicationController + include ProjectUnauthorized + + before_action :authorize_read_cluster! + + INDEX_PRIMING_INTERVAL = 10_000 + INDEX_POLLING_INTERVAL = 30_000 + + def index + finder = Projects::Serverless::FunctionsFinder.new(project.clusters) + + respond_to do |format| + format.json do + functions = finder.execute + + if functions.any? + Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) + render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) + else + Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) + head :no_content + end + end + + format.html do + @installed = finder.installed? + render + end + end + end + end + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 1d76c90d4eb..30724de7f6a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -5,6 +5,7 @@ module Projects class RepositoryController < Projects::ApplicationController before_action :authorize_admin_project! before_action :remote_mirror, only: [:show] + before_action :check_cleanup_feature_flag!, only: :cleanup def show render_show @@ -20,8 +21,26 @@ module Projects render_show end + def cleanup + cleanup_params = params.require(:project).permit(:bfg_object_map) + result = Projects::UpdateService.new(project, current_user, cleanup_params).execute + + if result[:status] == :success + RepositoryCleanupWorker.perform_async(project.id, current_user.id) + flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.') + else + flash[:alert] = _('Failed to upload object map file') + end + + redirect_to project_settings_repository_path(project) + end + private + def check_cleanup_feature_flag! + render_404 unless ::Feature.enabled?(:project_cleanup, project) + end + def render_show @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) @deploy_tokens = @project.deploy_tokens.active diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index c8442ff3592..686d66b10a3 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -3,6 +3,8 @@ class Projects::TagsController < Projects::ApplicationController include SortingHelper + prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -18,7 +20,7 @@ class Projects::TagsController < Projects::ApplicationController @tags = Kaminari.paginate_array(@tags).page(params[:page]) tag_names = @tags.map(&:name) - @tags_pipelines = @project.pipelines.latest_successful_for_refs(tag_names) + @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names) @releases = project.releases.where(tag: tag_names) respond_to do |format| diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7f4a9f5151b..8bf93bfd68d 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,6 +7,8 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown include SendFileUpload + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } + before_action :whitelist_query_limiting, only: [:create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :redirect_git_extension, only: [:show] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5b70c69d7f4..8b040dc080e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,6 +14,7 @@ class UsersController < ApplicationController calendar_activities: true skip_before_action :authenticate_user! + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } before_action :user, except: [:exists] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :snippets] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index e04e3a2a7e0..b73a3fa6e01 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -27,12 +27,13 @@ # created_before: datetime # updated_after: datetime # updated_before: datetime -# use_cte_for_search: boolean +# attempt_group_search_optimizations: boolean # class IssuableFinder prepend FinderWithCrossProjectAccess include FinderMethods include CreatedAtFilter + include Gitlab::Utils::StrongMemoize requires_cross_project_access unless: -> { project? } @@ -75,8 +76,9 @@ class IssuableFinder items = init_collection items = filter_items(items) - # This has to be last as we may use a CTE as an optimization fence by - # passing the use_cte_for_search param + # This has to be last as we may use a CTE as an optimization fence + # by passing the attempt_group_search_optimizations param and + # enabling the use_cte_for_group_issues_search feature flag # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) @@ -85,6 +87,8 @@ class IssuableFinder def filter_items(items) items = by_project(items) + items = by_group(items) + items = by_subquery(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -282,12 +286,31 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + def use_subquery_for_search? + strong_memoize(:use_subquery_for_search) do + attempt_group_search_optimizations? && + Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false) + end + end + + def use_cte_for_search? + strong_memoize(:use_cte_for_search) do + attempt_group_search_optimizations? && + !use_subquery_for_search? && + Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) + end + end + private def init_collection klass.all end + def attempt_group_search_optimizations? + search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations] + end + def count_key(value) Array(value).last.to_sym end @@ -351,12 +374,13 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def use_cte_for_search? - return false unless search - return false unless Gitlab::Database.postgresql? - return false unless Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) - - params[:use_cte_for_search] + # Wrap projects and groups in a subquery if the conditions are met. + def by_subquery(items) + if use_subquery_for_search? + klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord + else + items + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 35d0e1acce5..f5aadc42ff0 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -8,7 +8,7 @@ class PipelinesFinder def initialize(project, current_user, params = {}) @project = project @current_user = current_user - @pipelines = project.pipelines + @pipelines = project.all_pipelines @params = params end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb new file mode 100644 index 00000000000..2b5d67e79d7 --- /dev/null +++ b/app/finders/projects/serverless/functions_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsFinder + def initialize(clusters) + @clusters = clusters + end + + def execute + knative_services.flatten.compact + end + + def installed? + clusters_with_knative_installed.exists? + end + + private + + def knative_services + clusters_with_knative_installed.preload_knative.map do |cluster| + cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + end + end + + def clusters_with_knative_installed + @clusters.with_knative_installed + end + end + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index ed13c5cfdd6..3f69af50f25 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -2,7 +2,12 @@ module AppearancesHelper def brand_title - current_appearance&.title.presence || 'GitLab Community Edition' + current_appearance&.title.presence || default_brand_title + end + + def default_brand_title + # This resides in a separate method so that EE can easily redefine it. + 'GitLab Community Edition' end def brand_image diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 44f85e9c0f8..654fb9d9987 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -57,6 +57,10 @@ module AuthHelper auth_providers.reject { |provider| form_based_provider?(provider) } end + def display_providers_on_profile? + button_based_providers.any? + end + def providers_for_base_controller auth_providers.reject { |provider| LDAP_PROVIDER === provider } end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 638744a1426..bd42f00944f 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -140,6 +140,8 @@ module BlobHelper Gitlab::Sanitizers::SVG.clean(data) end + # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed + # and :workhorse_set_content_type flag is removed # If we blindly set the 'real' content type when serving a Git blob we # are enabling XSS attacks. An attacker could upload e.g. a Javascript # file to a Git repository, trick the browser of a victim into @@ -161,6 +163,8 @@ module BlobHelper end def content_disposition(blob, inline) + # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 + # is closed and :workhorse_set_content_type flag is removed return 'attachment' if blob.extension == 'svg' inline ? 'inline' : 'attachment' diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 7f071d55a6b..494c754e7d5 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -85,13 +85,14 @@ module ButtonHelper dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' }) end - def dropdown_item_with_description(title, description, href: nil, data: nil) + def dropdown_item_with_description(title, description, href: nil, data: nil, default: false) + active_class = "is-active" if default button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title') button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description content_tag (href ? :a : :span), (href ? button_content : title), - class: "#{title.downcase}-selector", + class: "#{title.downcase}-selector #{active_class}", href: (href if href), data: (data if data) end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index e9b9b9b7721..866fc555856 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -140,7 +140,7 @@ module GroupsHelper can?(current_user, "read_group_#{resource}".to_sym, @group) end - if can?(current_user, :read_cluster, @group) && Feature.enabled?(:group_clusters) + if can?(current_user, :read_cluster, @group) && @group.group_clusters_enabled? links << :kubernetes end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index b0f63de2fb8..4e11772b252 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -42,7 +42,7 @@ module IconsHelper end def sprite_icon(icon_name, size: nil, css_class: nil) - if Gitlab::Sentry.should_raise? + if Gitlab::Sentry.should_raise_for_dev? unless known_sprites.include?(icon_name) exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") raise exception diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb new file mode 100644 index 00000000000..8e50bbc6c04 --- /dev/null +++ b/app/helpers/ide_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module IdeHelper + def ide_data + { + "empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), + "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), + "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), + "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), + "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), + "ci-help-page-path" => help_page_path('ci/quick_start/README'), + "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s + } + end +end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 94a030d9d57..9666080092b 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -2,6 +2,7 @@ module MilestonesHelper include EntityDateHelper + include Gitlab::Utils::StrongMemoize def milestones_filter_path(opts = {}) if @project @@ -243,4 +244,16 @@ module MilestonesHelper dashboard_milestone_path(milestone.safe_title, title: milestone.title) end end + + def can_admin_project_milestones? + strong_memoize(:can_admin_project_milestones) do + can?(current_user, :admin_milestone, @project) + end + end + + def can_admin_group_milestones? + strong_memoize(:can_admin_group_milestones) do + can?(current_user, :admin_milestone, @project.group) + end + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 0a7f930110a..7ce6b04df7e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -257,6 +257,10 @@ module ProjectsHelper "xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}" end + def link_to_bfg + link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' + end + def legacy_render_context(params) params[:legacy_render] ? { markdown_engine: :redcarpet } : {} end @@ -307,6 +311,7 @@ module ProjectsHelper settings: :admin_project, builds: :read_build, clusters: :read_cluster, + serverless: :read_cluster, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -545,6 +550,7 @@ module ProjectsHelper %w[ environments clusters + functions user gcp ] diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb deleted file mode 100644 index d53eaef9952..00000000000 --- a/app/helpers/sentry_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module SentryHelper - def sentry_enabled? - Gitlab::Sentry.enabled? - end - - def sentry_context - Gitlab::Sentry.context(current_user) - end -end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 8ed2a2ec9f4..f51b96ba8ce 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -120,10 +120,69 @@ module SortingHelper } end + def users_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sortable_item(item, path, sorted_by) link_to item, path, class: sorted_by == item ? 'is-active' : '' end + def issuable_sort_option_overrides + { + sort_value_oldest_created => sort_value_created_date, + sort_value_oldest_updated => sort_value_recently_updated, + sort_value_milestone_later => sort_value_milestone + } + end + + def issuable_reverse_sort_order_hash + { + sort_value_created_date => sort_value_oldest_created, + sort_value_recently_created => sort_value_oldest_created, + sort_value_recently_updated => sort_value_oldest_updated, + sort_value_milestone => sort_value_milestone_later + }.merge(issuable_sort_option_overrides) + end + + def issuable_sort_option_title(sort_value) + sort_value = issuable_sort_option_overrides[sort_value] || sort_value + + sort_options_hash[sort_value] + end + + def issuable_sort_direction_button(sort_value) + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reverse_sort = issuable_reverse_sort_order_hash[sort_value] + + if reverse_sort + reverse_url = page_filter_path(sort: reverse_sort) + else + reverse_url = '#' + link_class += ' disabled' + end + + link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do + icon_suffix = + case sort_value + when sort_value_milestone, sort_value_due_date, /_asc\z/ + 'lowest' + else + 'highest' + end + + sprite_icon("sort-#{icon_suffix}", size: 16) + end + end + # Titles. def sort_title_access_level_asc s_('SortOptions|Access level, ascending') diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 42b533ad772..bde9ca0cbf2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -70,6 +70,10 @@ module UsersHelper end end + def impersonation_enabled? + Gitlab.config.gitlab.impersonation_enabled + end + private def get_profile_tabs diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index e690350a0d1..712f0f808dd 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -140,7 +140,7 @@ module VisibilityLevelHelper end def project_visibility_icon_description(level) - "#{project_visibility_level_description(level)}" + "#{visibility_level_label(level)} - #{project_visibility_level_description(level)}" end def visibility_level_label(level) diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 49c08dce96c..e9fc39e451b 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -6,8 +6,13 @@ module WorkhorseHelper # Send a Git blob through Workhorse def send_git_blob(repository, blob, inline: true) headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) + headers['Content-Disposition'] = content_disposition(blob, inline) headers['Content-Type'] = safe_content_type(blob) + + # If enabled, this will override the values set above + workhorse_set_content_type! + render plain: "" end @@ -40,4 +45,8 @@ module WorkhorseHelper def set_workhorse_internal_api_content_type headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE end + + def workhorse_set_content_type! + headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type) + end end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index d3284e90568..1b3c1f9a8a9 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -26,7 +26,7 @@ module Emails mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id)) end - def note_snippet_email(recipient_id, note_id) + def note_project_snippet_email(recipient_id, note_id) setup_note_mail(note_id, recipient_id) @snippet = @note.noteable diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index d7e6c2ba7b2..2500622caa7 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -24,6 +24,21 @@ module Emails subject: subject("Project export error")) end + def repository_cleanup_success_email(project, user) + @project = project + @user = user + + mail(to: user.notification_email, subject: subject("Project cleanup has completed")) + end + + def repository_cleanup_failure_email(project, user, error) + @project = project + @user = user + @error = error + + mail(to: user.notification_email, subject: subject("Project cleanup failure")) + end + def repository_push_email(project_id, opts = {}) @message = Gitlab::Email::Message::RepositoryPush.new(self, project_id, opts) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 207ffae873a..4319db42019 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -7,7 +7,7 @@ class ApplicationSetting < ActiveRecord::Base include IgnorableColumn include ChronicDurationAttribute - add_authentication_token_field :runners_registration_token + add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true add_authentication_token_field :health_check_access_token DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d60861dc95f..d86a6eceb59 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -120,7 +120,7 @@ module Ci acts_as_taggable - add_authentication_token_field :token + add_authentication_token_field :token, encrypted: true, fallback: true before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7c84bd734bb..da08214963f 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -15,6 +15,8 @@ module Ci WRITE_LOCK_SLEEP = 0.01.seconds WRITE_LOCK_TTL = 1.minute + FailedToPersistDataError = Class.new(StandardError) + # Note: The ordering of this enum is related to the precedence of persist store. # The bottom item takes the higest precedence, and the top item takes the lowest precedence. enum data_store: { @@ -109,16 +111,19 @@ module Ci def unsafe_persist_to!(new_store) return if data_store == new_store.to_s - raise ArgumentError, 'Can not persist empty data' unless size > 0 - old_store_class = self.class.get_store_class(data_store) + current_data = get_data - get_data.tap do |the_data| - self.raw_data = nil - self.data_store = new_store - unsafe_set_data!(the_data) + unless current_data&.bytesize.to_i == CHUNK_SIZE + raise FailedToPersistDataError, 'Data is not fullfilled in a bucket' end + old_store_class = self.class.get_store_class(data_store) + + self.raw_data = nil + self.data_store = new_store + unsafe_set_data!(current_data) + old_store_class.delete_data(self) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9512ba42f67..d06022a0fb7 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -12,13 +12,14 @@ module Ci include AtomicInternalId include EnumWithNil - belongs_to :project, inverse_of: :pipelines + belongs_to :project, inverse_of: :all_pipelines belongs_to :user belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' + belongs_to :merge_request, class_name: 'MergeRequest' has_internal_id :iid, scope: :project, presence: false, init: ->(s) do - s&.project&.pipelines&.maximum(:iid) || s&.project&.pipelines&.count + s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline @@ -26,6 +27,8 @@ module Ci has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' + has_many :deployments, through: :builds + has_many :environments, -> { distinct }, through: :deployments # Merge requests for which the current pipeline is running against # the merge request's latest commit. @@ -48,6 +51,9 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } + validates :merge_request, presence: { if: :merge_request? } + validates :merge_request, absence: { unless: :merge_request? } + validates :tag, inclusion: { in: [false], if: :merge_request? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? @@ -168,6 +174,16 @@ module Ci end scope :internal, -> { where(source: internal_sources) } + scope :ci_sources, -> { where(config_source: ci_sources_values) } + + scope :sort_by_merge_request_pipelines, -> do + sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' + query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend + + order(query) + end + + scope :for_user, -> (user) { where(user: user) } # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. @@ -256,6 +272,10 @@ module Ci sources.reject { |source| source == "external" }.values end + def self.ci_sources_values + config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source) + end + def stages_count statuses.select(:stage).distinct.count end @@ -368,7 +388,7 @@ module Ci end def branch? - !tag? + !tag? && !merge_request? end def stuck? @@ -494,6 +514,8 @@ module Ci end def ci_yaml_file_path + return unless repository_source? || unknown_source? + if project.ci_config_path.blank? '.gitlab-ci.yml' else @@ -523,10 +545,6 @@ module Ci yaml_errors.present? end - def environments - builds.where.not(environment: nil).success.pluck(:environment).uniq - end - # Manually set the notes for a Ci::Pipeline # There is no ActiveRecord relation between Ci::Pipeline and notes # as they are related to a commit sha. This method helps importing @@ -587,13 +605,18 @@ module Ci end def predefined_variables - Gitlab::Ci::Variables::Collection.new - .append(key: 'CI_PIPELINE_IID', value: iid.to_s) - .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) - .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) + variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) + variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + + if merge_request? && merge_request + variables.concat(merge_request.predefined_variables) + end + end end def queued_duration @@ -617,7 +640,12 @@ module Ci # All the merge requests for which the current pipeline runs/ran against def all_merge_requests - @all_merge_requests ||= project.merge_requests.where(source_branch: ref) + @all_merge_requests ||= + if merge_request? + project.merge_requests.where(id: merge_request.id) + else + project.merge_requests.where(source_branch: ref) + end end def detailed_status(current_user) @@ -666,6 +694,7 @@ module Ci def ci_yaml_from_repo return unless project return unless sha + return unless ci_yaml_file_path project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path) rescue GRPC::NotFound, GRPC::Internal @@ -693,6 +722,8 @@ module Ci def git_ref if branch? Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s + elsif merge_request? + Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s elsif tag? Gitlab::Git::TAG_REF_PREFIX + ref.to_s else diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 8d8d16e2ec1..c0f16066e0b 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -21,7 +21,8 @@ module Ci trigger: 3, schedule: 4, api: 5, - external: 6 + external: 6, + merge_request: 10 } end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 31330d0682e..2693386443a 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -8,6 +8,9 @@ module Ci include RedisCacheable include ChronicDurationAttribute include FromUnion + include TokenAuthenticatable + + add_authentication_token_field :token, encrypted: true, migrating: true enum access_level: { not_protected: 0, @@ -39,7 +42,7 @@ module Ci has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' - before_validation :set_default_values + before_save :ensure_token scope :active, -> { where(active: true) } scope :paused, -> { where(active: false) } @@ -111,7 +114,8 @@ module Ci cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at - chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout + chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout, + error_message: 'Maximum job timeout has a value which could not be accepted' validates :maximum_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 600, @@ -145,10 +149,6 @@ module Ci end end - def set_default_values - self.token = SecureRandom.hex(15) if self.token.blank? - end - def assign_to(project, current_user = nil) if instance_type? self.runner_type = :project_type diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index e43a0fd1786..421a923d386 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -56,7 +56,11 @@ module Clusters def specification { "ingress" => { - "hosts" => [hostname] + "hosts" => [hostname], + "tls" => [{ + "hosts" => [hostname], + "secretName" => "jupyter-cert" + }] }, "hub" => { "extraEnv" => { diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index c0aaa8dce20..168a24da738 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,6 +15,9 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue + include ReactiveCaching + + self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } state_machine :status do before_transition any => [:installed] do |application| @@ -29,6 +32,8 @@ module Clusters validates :hostname, presence: true, hostname: true + scope :for_cluster, -> (cluster) { where(cluster: cluster) } + def chart 'knative/knative' end @@ -55,12 +60,39 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end + def client + cluster.kubeclient.knative_client + end + + def services + with_reactive_cache do |data| + data[:services] + end + end + + def calculate_reactive_cache + { services: read_services } + end + def ingress_service cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system') end - def client - cluster.platform_kubernetes.kubeclient.knative_client + def services_for(ns: namespace) + return unless services + return [] unless ns + + services.select do |service| + service.dig('metadata', 'namespace') == ns + end + end + + private + + def read_services + client.get_services.as_json + rescue Kubeclient::ResourceNotFoundError + [] end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 67746e34913..c931b340b24 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.38'.freeze + VERSION = '0.1.39'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 13906c903b9..7fe43cd2de0 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -4,6 +4,7 @@ module Clusters class Cluster < ActiveRecord::Base include Presentable include Gitlab::Utils::StrongMemoize + include FromUnion self.table_name = 'clusters' @@ -86,6 +87,29 @@ module Clusters scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } + scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do + subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.cluster_id = clusters.id') + + where('NOT EXISTS (?)', subquery) + end + + scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) } + + scope :preload_knative, -> { + preload( + :kubernetes_namespace, + :platform_kubernetes, + :application_knative + ) + } + + def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) + hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters) + hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope + + hierarchy_groups.flat_map(&:clusters) + end + def status_name if provider provider.status_name @@ -122,6 +146,16 @@ module Clusters !user? end + def all_projects + if project_type? + projects + elsif group_type? + first_group.all_projects + else + Project.none + end + end + def first_project strong_memoize(:first_project) do projects.first @@ -140,11 +174,17 @@ module Clusters platform_kubernetes.kubeclient if kubernetes? end - def find_or_initialize_kubernetes_namespace(cluster_project) - kubernetes_namespaces.find_or_initialize_by( - project: cluster_project.project, - cluster_project: cluster_project - ) + def find_or_initialize_kubernetes_namespace_for_project(project) + if project_type? + kubernetes_namespaces.find_or_initialize_by( + project: project, + cluster_project: cluster_project + ) + else + kubernetes_namespaces.find_or_initialize_by( + project: project + ) + end end def allow_user_defined_namespace? diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb index 34f5e38ff79..73da6cb37d7 100644 --- a/app/models/clusters/kubernetes_namespace.rb +++ b/app/models/clusters/kubernetes_namespace.rb @@ -33,14 +33,12 @@ module Clusters end def predefined_variables - config = YAML.dump(kubeconfig) - Gitlab::Ci::Variables::Collection.new.tap do |variables| variables .append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name.to_s) .append(key: 'KUBE_NAMESPACE', value: namespace.to_s) .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 3c5d7756eec..867f0edcb07 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -85,18 +85,16 @@ module Clusters if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project) variables.concat(kubernetes_namespace.predefined_variables) - else + elsif cluster.project_type? # From 11.5, every Clusters::Project should have at least one # Clusters::KubernetesNamespace, so once migration has been completed, # this 'else' branch will be removed. For more information, please see # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433 - config = YAML.dump(kubeconfig) - variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false) .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 546fcc54a15..a422a0995ff 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -177,7 +177,9 @@ class Commit def title return full_title if full_title.length < 100 - full_title.truncate(81, separator: ' ', omission: '…') + # Use three dots instead of the ellipsis Unicode character because + # some clients show the raw Unicode value in the merge commit. + full_title.truncate(81, separator: ' ', omission: '...') end # Returns the full commits title @@ -298,7 +300,7 @@ class Commit end def pipelines - project.pipelines.where(sha: sha) + project.ci_pipelines.where(sha: sha) end def last_pipeline @@ -312,7 +314,7 @@ class Commit end def status_for_project(ref, pipeline_project) - pipeline_project.pipelines.latest_status_per_commit(id, ref)[id] + pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id] end def set_status_for_ref(ref, status) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index dd93af9df64..e349f0fe971 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -24,7 +24,7 @@ class CommitCollection # Setting this status ahead of time removes the need for running a query for # every commit we're displaying. def with_pipeline_status - statuses = project.pipelines.latest_status_per_commit(map(&:id), ref) + statuses = project.ci_pipelines.latest_status_per_commit(map(&:id), ref) each do |commit| commit.set_status_for_ref(ref, statuses[commit.id]) diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 60b7ec2815c..14bc56f0eee 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -43,14 +43,19 @@ module Awardable end def order_upvotes_desc - order_votes_desc(AwardEmoji::UPVOTE_NAME) + order_votes(AwardEmoji::UPVOTE_NAME, 'DESC') + end + + def order_upvotes_asc + order_votes(AwardEmoji::UPVOTE_NAME, 'ASC') end def order_downvotes_desc - order_votes_desc(AwardEmoji::DOWNVOTE_NAME) + order_votes(AwardEmoji::DOWNVOTE_NAME, 'DESC') end - def order_votes_desc(emoji_name) + # Order votes by emoji, optional sort order param `descending` defaults to true + def order_votes(emoji_name, direction) awardable_table = self.arel_table awards_table = AwardEmoji.arel_table @@ -62,7 +67,7 @@ module Awardable ) ).join_sources - joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC") + joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}") end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 6e2adc76ec6..a8c9e54f00c 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -15,7 +15,7 @@ module CacheMarkdownField # Increment this number every time the renderer changes its output CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 11 + CACHE_COMMONMARK_VERSION = 12 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index edf6ac96730..af4905115b1 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -24,7 +24,7 @@ module ChronicDurationAttribute end end - validates virtual_attribute, allow_nil: true, duration: true + validates virtual_attribute, allow_nil: true, duration: { message: parameters[:error_message] } end alias_method :chronic_duration_attr, :chronic_duration_attr_writer diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index e57a3383544..0107af5f8ec 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -13,6 +13,7 @@ module DeploymentPlatform def find_deployment_platform(environment) find_cluster_platform_kubernetes(environment: environment) || + find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) || find_kubernetes_service_integration || build_cluster_and_deployment_platform end @@ -23,6 +24,18 @@ module DeploymentPlatform .last&.platform_kubernetes end + def find_group_cluster_platform_kubernetes_with_feature_guard(environment: nil) + return unless group_clusters_enabled? + + find_group_cluster_platform_kubernetes(environment: environment) + end + + # EE would override this and utilize environment argument + def find_group_cluster_platform_kubernetes(environment: nil) + Clusters::Cluster.enabled.default_environment.ancestor_clusters_for_clusterable(self) + .first&.platform_kubernetes + end + def find_kubernetes_service_integration services.deployment.reorder(nil).find_by(active: true) end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index c180d7b7c9a..266c37fa3a1 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -38,12 +38,13 @@ module DiscussionOnDiff end # Returns an array of at most 16 highlighted lines above a diff note - def truncated_diff_lines(highlight: true) + def truncated_diff_lines(highlight: true, diff_limit: nil) return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote) + diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min lines = highlight ? highlighted_diff_lines : diff_lines - initial_line_index = [diff_line.index - NUMBER_OF_TRUNCATED_DIFF_LINES + 1, 0].max + initial_line_index = [diff_line.index - diff_limit + 1, 0].max prev_lines = [] diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 2bfa7da6c1c..1e3afd641ed 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -70,13 +70,14 @@ module FastDestroyAll module Helpers extend ActiveSupport::Concern + include AfterCommitQueue class_methods do ## # This method is to be defined on models which have fast destroyable models as children, # and let us avoid to use `dependent: :destroy` hook - def use_fast_destroy(relation) - before_destroy(prepend: true) do + def use_fast_destroy(relation, opts = {}) + set_callback :destroy, :before, opts.merge(prepend: true) do perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5080fe03cc8..0d363ec68b7 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -145,14 +145,16 @@ module Issuable def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s - when 'downvotes_desc' then order_downvotes_desc - when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) - when 'milestone' then order_milestone_due_asc - when 'milestone_due_asc' then order_milestone_due_asc - when 'milestone_due_desc' then order_milestone_due_desc - when 'popularity' then order_upvotes_desc - when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) - when 'upvotes_desc' then order_upvotes_desc + when 'downvotes_desc' then order_downvotes_desc + when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels) + when 'milestone', 'milestone_due_asc' then order_milestone_due_asc + when 'milestone_due_desc' then order_milestone_due_desc + when 'popularity', 'popularity_desc' then order_upvotes_desc + when 'popularity_asc' then order_upvotes_asc + when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) + when 'upvotes_desc' then order_upvotes_desc else order_by(method) end @@ -160,7 +162,7 @@ module Issuable sorted.with_order_id_desc end - def order_due_date_and_labels_priority(excluded_labels: []) + def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: []) # The order_ methods also modify the query in other ways: # # - For milestones, we add a JOIN. @@ -177,11 +179,11 @@ module Issuable order_milestone_due_asc .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]) - .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'), - Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction), + Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def order_labels_priority(excluded_labels: [], extra_select_columns: []) + def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: []) params = { target_type: name, target_column: "#{table_name}.id", @@ -198,7 +200,7 @@ module Issuable select(select_columns.join(', ')) .group(arel_table[:id]) - .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction)) end def with_label(title, sort = nil) diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb new file mode 100644 index 00000000000..57cd77b44b4 --- /dev/null +++ b/app/models/concerns/shardable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Shardable + extend ActiveSupport::Concern + + included do + belongs_to :shard + validates :shard, presence: true + end + + def shard_name + shard&.name + end + + def shard_name=(name) + self.shard = Shard.by_name(name) + end +end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 23a43aec677..f5bb559ceda 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -9,24 +9,18 @@ module TokenAuthenticatable private # rubocop:disable Lint/UselessAccessModifier def add_authentication_token_field(token_field, options = {}) - @token_fields = [] unless @token_fields - unique = options.fetch(:unique, true) - - if @token_fields.include?(token_field) + if token_authenticatable_fields.include?(token_field) raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field") end - @token_fields << token_field + token_authenticatable_fields.push(token_field) attr_accessor :cleartext_tokens - strategy = if options[:digest] - TokenAuthenticatableStrategies::Digest.new(self, token_field, options) - else - TokenAuthenticatableStrategies::Insecure.new(self, token_field, options) - end + strategy = TokenAuthenticatableStrategies::Base + .fabricate(self, token_field, options) - if unique + if options.fetch(:unique, true) define_singleton_method("find_by_#{token_field}") do |token| strategy.find_token_authenticatable(token) end @@ -53,6 +47,15 @@ module TokenAuthenticatable define_method("reset_#{token_field}!") do strategy.reset_token!(self) end + + define_method("#{token_field}_matches?") do |other_token| + token = read_attribute(token_field) + token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(other_token, token) + end + end + + def token_authenticatable_fields + @token_authenticatable_fields ||= [] end end end diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 413721d3e6c..01fb194281a 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -2,6 +2,8 @@ module TokenAuthenticatableStrategies class Base + attr_reader :klass, :token_field, :options + def initialize(klass, token_field, options) @klass = klass @token_field = token_field @@ -22,6 +24,7 @@ module TokenAuthenticatableStrategies def ensure_token(instance) write_new_token(instance) unless token_set?(instance) + get_token(instance) end # Returns a token, but only saves when the database is in read & write mode @@ -36,6 +39,36 @@ module TokenAuthenticatableStrategies instance.save! if Gitlab::Database.read_write? end + def fallback? + unless options[:fallback].in?([true, false, nil]) + raise ArgumentError, 'fallback: needs to be a boolean value!' + end + + options[:fallback] == true + end + + def migrating? + unless options[:migrating].in?([true, false, nil]) + raise ArgumentError, 'migrating: needs to be a boolean value!' + end + + options[:migrating] == true + end + + def self.fabricate(model, field, options) + if options[:digest] && options[:encrypted] + raise ArgumentError, 'Incompatible options set!' + end + + if options[:digest] + TokenAuthenticatableStrategies::Digest.new(model, field, options) + elsif options[:encrypted] + TokenAuthenticatableStrategies::Encrypted.new(model, field, options) + else + TokenAuthenticatableStrategies::Insecure.new(model, field, options) + end + end + protected def write_new_token(instance) @@ -65,9 +98,5 @@ module TokenAuthenticatableStrategies def token_set?(instance) raise NotImplementedError end - - def token_field_name - @token_field - end end end diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb new file mode 100644 index 00000000000..152491aa6e9 --- /dev/null +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module TokenAuthenticatableStrategies + class Encrypted < Base + def initialize(*) + super + + if migrating? && fallback? + raise ArgumentError, '`fallback` and `migrating` options are not compatible!' + end + end + + def find_token_authenticatable(token, unscoped = false) + return if token.blank? + + if fully_encrypted? + return find_by_encrypted_token(token, unscoped) + end + + if fallback? + find_by_encrypted_token(token, unscoped) || + find_by_plaintext_token(token, unscoped) + elsif migrating? + find_by_plaintext_token(token, unscoped) + else + raise ArgumentError, 'Unknown encryption phase!' + end + end + + def ensure_token(instance) + # TODO, tech debt, because some specs are testing migrations, but are still + # using factory bot to create resources, it might happen that a database + # schema does not have "#{token_name}_encrypted" field yet, however a bunch + # of models call `ensure_#{token_name}` in `before_save`. + # + # In that case we are using insecure strategy, but this should only happen + # in tests, because otherwise `encrypted_field` is going to exist. + # + # Another use case is when we are caching resources / columns, like we do + # in case of ApplicationSetting. + + return super if instance.has_attribute?(encrypted_field) + + if fully_encrypted? + raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!' + else + insecure_strategy.ensure_token(instance) + end + end + + def get_token(instance) + return insecure_strategy.get_token(instance) if migrating? + + encrypted_token = instance.read_attribute(encrypted_field) + token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) + + token || (insecure_strategy.get_token(instance) if fallback?) + end + + def set_token(instance, token) + raise ArgumentError unless token.present? + + instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + instance[token_field] = token if migrating? + instance[token_field] = nil if fallback? + token + end + + def fully_encrypted? + !migrating? && !fallback? + end + + protected + + def find_by_plaintext_token(token, unscoped) + insecure_strategy.find_token_authenticatable(token, unscoped) + end + + def find_by_encrypted_token(token, unscoped) + encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + relation(unscoped).find_by(encrypted_field => encrypted_value) + end + + def insecure_strategy + @insecure_strategy ||= TokenAuthenticatableStrategies::Insecure + .new(klass, token_field, options) + end + + def token_set?(instance) + raw_token = instance.read_attribute(encrypted_field) + + unless fully_encrypted? + raw_token ||= insecure_strategy.get_token(instance) + end + + raw_token.present? + end + + def encrypted_field + @encrypted_field ||= "#{@token_field}_encrypted" + end + end +end diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index 2bdef2a40e4..d79c0eae77e 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -17,6 +17,8 @@ module WithUploads extend ActiveSupport::Concern + include FastDestroyAll::Helpers + include FeatureGate # Currently there is no simple way how to select only not-mounted # uploads, it should be all FileUploaders so we select them by @@ -25,21 +27,40 @@ module WithUploads included do has_many :uploads, as: :model + has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model - before_destroy :destroy_file_uploads + # TODO: when feature flag is removed, we can use just dependent: destroy + # option on :file_uploads + before_destroy :remove_file_uploads + + use_fast_destroy :file_uploads, if: :fast_destroy_enabled? + end + + def retrieve_upload(_identifier, paths) + uploads.find_by(path: paths) end + private + # mounted uploads are deleted in carrierwave's after_commit hook, # but FileUploaders which are not mounted must be deleted explicitly and # it can not be done in after_commit because FileUploader requires loads # associated model on destroy (which is already deleted in after_commit) - def destroy_file_uploads - self.uploads.where(uploader: FILE_UPLOADERS).find_each do |upload| + def remove_file_uploads + fast_destroy_enabled? ? delete_uploads : destroy_uploads + end + + def delete_uploads + file_uploads.delete_all(:delete_all) + end + + def destroy_uploads + file_uploads.find_each do |upload| upload.destroy end end - def retrieve_upload(_identifier, paths) - uploads.find_by(path: paths) + def fast_destroy_enabled? + Feature.enabled?(:fast_destroy_uploads, self) end end diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 7078496ff52..2fb6cadc8cd 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -8,16 +8,17 @@ class EnvironmentStatus delegate :id, to: :environment delegate :name, to: :environment delegate :project, to: :environment + delegate :status, to: :deployment, allow_nil: true delegate :deployed_at, to: :deployment, allow_nil: true def self.for_merge_request(mr, user) - build_environments_status(mr, user, mr.diff_head_sha) + build_environments_status(mr, user, mr.actual_head_pipeline) end def self.after_merge_request(mr, user) return [] unless mr.merged? - build_environments_status(mr, user, mr.merge_commit_sha) + build_environments_status(mr, user, mr.merge_pipeline) end def initialize(environment, merge_request, sha) @@ -43,22 +44,6 @@ class EnvironmentStatus .merge_request_diff_files.where(deleted_file: false) end - ## - # Since frontend has not supported all statuses yet, BE has to - # proxy some status to a supported status. - def status - return unless deployment - - case deployment.status - when 'created' - 'running' - when 'canceled' - 'failed' - else - deployment.status - end - end - private PAGE_EXTENSIONS = /\A\.(s?html?|php|asp|cgi|pl)\z/i.freeze @@ -76,13 +61,13 @@ class EnvironmentStatus } end - def self.build_environments_status(mr, user, sha) - Environment.where(project_id: [mr.source_project_id, mr.target_project_id]) - .available - .with_deployment(sha).map do |environment| + def self.build_environments_status(mr, user, pipeline) + return [] unless pipeline + + pipeline.environments.available.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) - EnvironmentStatus.new(environment, mr, sha) + EnvironmentStatus.new(environment, mr, pipeline.sha) end.compact end private_class_method :build_environments_status diff --git a/app/models/group.rb b/app/models/group.rb index adb9169cfcd..233747cc2c2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -55,7 +55,7 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } - add_authentication_token_field :runners_token + add_authentication_token_field :runners_token, encrypted: true, migrating: true after_create :post_create_hook after_destroy :post_destroy_hook @@ -400,6 +400,10 @@ class Group < Namespace ensure_runners_token! end + def group_clusters_enabled? + Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true) + end + private def update_two_factor_requirement diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index b2fb79bc7ed..1a8662db9fb 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -6,12 +6,12 @@ class WebHook < ActiveRecord::Base attr_encrypted :token, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_truncated + key: Settings.attr_encrypted_db_key_base_32 has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent diff --git a/app/models/issue.rb b/app/models/issue.rb index 780035c77e2..b7e13bcbccf 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -235,20 +235,6 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.key?(:issue_endpoints) && project - url_helper = Gitlab::Routing.url_helpers - - issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference - - json.merge!( - reference_path: issue_reference, - real_path: url_helper.project_issue_path(project, self), - issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'), - toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self), - assignable_labels_endpoint: url_helper.project_labels_path(project, format: :json, include_ancestor_groups: true) - ) - end - if options.key?(:labels) json[:labels] = labels.as_json( project: project, diff --git a/app/models/member.rb b/app/models/member.rb index bc8ac14d148..9fc95ea00c3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -7,6 +7,7 @@ class Member < ActiveRecord::Base include Expirable include Gitlab::Access include Presentable + include Gitlab::Utils::StrongMemoize attr_accessor :raw_invite_token @@ -22,6 +23,7 @@ class Member < ActiveRecord::Base message: "already exists in source", allow_nil: true } validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validate :higher_access_level_than_group, unless: :importing? validates :invite_email, presence: { if: :invite? @@ -364,6 +366,15 @@ class Member < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + # Find the user's group member with a highest access level + def highest_group_member + strong_memoize(:highest_group_member) do + next unless user_id && source&.ancestors&.any? + + GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last + end + end + private def send_invite @@ -430,4 +441,12 @@ class Member < ActiveRecord::Base def notifiable_options {} end + + def higher_access_level_than_group + if highest_group_member && highest_group_member.access_level >= access_level + error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name } + + errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters) + end + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 92add079a02..861211ffc0a 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -63,6 +63,7 @@ class MergeRequest < ActiveRecord::Base dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue + has_many :merge_request_pipelines, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' belongs_to :assignee, class_name: "User" @@ -538,15 +539,26 @@ class MergeRequest < ActiveRecord::Base def validate_branches if target_project == source_project && target_branch == source_branch - errors.add :branch_conflict, "You can not use same project/branch for source and target" + errors.add :branch_conflict, "You can't use same project/branch for source and target" + return end if opened? - similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened - similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id - if similar_mrs.any? - errors.add :validate_branches, - "Cannot Create: This merge request already exists: #{similar_mrs.pluck(:title)}" + similar_mrs = target_project + .merge_requests + .where(source_branch: source_branch, target_branch: target_branch) + .where(source_project_id: source_project&.id) + .opened + + similar_mrs = similar_mrs.where.not(id: id) if persisted? + + conflict = similar_mrs.first + + if conflict.present? + errors.add( + :validate_branches, + "Another open merge request already exists for this source branch: #{conflict.to_reference}" + ) end end end @@ -1052,18 +1064,59 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def all_pipelines + def all_pipelines(shas: all_commit_shas) return Ci::Pipeline.none unless source_project - @all_pipelines ||= source_project.pipelines - .where(sha: all_commit_shas, ref: source_branch) - .order(id: :desc) + @all_pipelines ||= source_project.ci_pipelines + .where(sha: shas, ref: source_branch) + .where(merge_request: [nil, self]) + .sort_by_merge_request_pipelines + end + + def merge_request_pipeline_exists? + merge_request_pipelines.exists?(sha: diff_head_sha) end def has_test_reports? actual_head_pipeline&.has_test_reports? end + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', + value: ref_path.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', + value: project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', + value: project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', + value: project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', + value: target_branch.to_s) + + if source_project + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', + value: source_project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', + value: source_project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', + value: source_project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', + value: source_branch.to_s) + end + end + end + # rubocop: disable CodeReuse/ServiceClass def compare_test_reports unless has_test_reports? @@ -1214,7 +1267,7 @@ class MergeRequest < ActiveRecord::Base end def base_pipeline - @base_pipeline ||= project.pipelines + @base_pipeline ||= project.ci_pipelines .order(id: :desc) .find_by(sha: diff_base_sha) end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 11b03846f0b..8865c164b11 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -192,9 +192,9 @@ class Namespace < ActiveRecord::Base # returns all ancestors upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned - def ancestors_upto(top = nil) + def ancestors_upto(top = nil, hierarchy_order: nil) Gitlab::GroupHierarchy.new(self.class.where(id: id)) - .ancestors(upto: top) + .ancestors(upto: top, hierarchy_order: hierarchy_order) end def self_and_ancestors @@ -243,7 +243,7 @@ class Namespace < ActiveRecord::Base end def root_ancestor - ancestors.reorder(nil).find_by(parent_id: nil) + self_and_ancestors.reorder(nil).find_by(parent_id: nil) end def subgroup? diff --git a/app/models/note.rb b/app/models/note.rb index 592efb714f3..a6ae4f58ac4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -324,7 +324,7 @@ class Note < ActiveRecord::Base end def to_ability_name - for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore + for_snippet? ? noteable.class.name.underscore : noteable_type.underscore end def can_be_discussion_note? diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 1600acfc575..e82eaf4e069 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -5,7 +5,7 @@ class NotificationSetting < ActiveRecord::Base ignore_column :events - enum level: { global: 3, watch: 2, mention: 4, participating: 1, disabled: 0, custom: 5 } + enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 } default_value_for :level, NotificationSetting.levels[:global] diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 8ef74539209..bad0e30ceb5 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -1,22 +1,20 @@ # frozen_string_literal: true class PoolRepository < ActiveRecord::Base - POOL_PREFIX = '@pools' + include Shardable - belongs_to :shard - validates :shard, presence: true + has_many :member_projects, class_name: 'Project' - # For now, only pool repositories are tracked in the database. However, we may - # want to add other repository types in the future - self.table_name = 'repositories' + after_create :correct_disk_path - has_many :pool_member_projects, class_name: 'Project', foreign_key: :pool_repository_id + private - def shard_name - shard&.name + def correct_disk_path + update!(disk_path: storage.disk_path) end - def shard_name=(name) - self.shard = Shard.by_name(name) + def storage + Storage::HashedProject + .new(self, prefix: Storage::HashedProject::POOL_PATH_PREFIX) end end diff --git a/app/models/project.rb b/app/models/project.rb index 39978d8a4c4..9e736a3b03c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -30,6 +30,7 @@ class Project < ActiveRecord::Base include FeatureGate include OptionallySearch include FromUnion + include IgnorableColumn extend Gitlab::Cache::RequestCache extend Gitlab::ConfigHelper @@ -55,6 +56,8 @@ class Project < ActiveRecord::Base VALID_MIRROR_PORTS = [22, 80, 443].freeze VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze + ignore_column :import_status, :import_jid, :import_error + cache_markdown_field :description, pipeline: :description delegate :feature_available?, :builds_enabled?, :wiki_enabled?, @@ -63,6 +66,12 @@ class Project < ActiveRecord::Base delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage + delegate :scheduled?, :started?, :in_progress?, + :failed?, :finished?, + prefix: :import, to: :import_state, allow_nil: true + + delegate :no_import?, to: :import_state, allow_nil: true + default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level default_value_for :resolve_outdated_diff_discussions, false @@ -76,7 +85,7 @@ class Project < ActiveRecord::Base default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false - add_authentication_token_field :runners_token + add_authentication_token_field :runners_token, encrypted: true, migrating: true before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } @@ -177,6 +186,7 @@ class Project < ActiveRecord::Base has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :project_repository, inverse_of: :project # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -228,6 +238,7 @@ class Project < ActiveRecord::Base has_one :cluster_project, class_name: 'Clusters::Project' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress' + has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' has_many :prometheus_metrics @@ -237,7 +248,17 @@ class Project < ActiveRecord::Base has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :commit_statuses - has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + # The relation :all_pipelines is intented to be used when we want to get the + # whole list of pipelines associated to the project + has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + # The relation :ci_pipelines is intented to be used when we want to get only + # those pipeline which are directly related to CI. There are + # other pipelines, like webide ones, that we won't retrieve + # if we use this relation. + has_many :ci_pipelines, + -> { Feature.enabled?(:pipeline_ci_sources_only, default_enabled: true) ? ci_sources : all }, + class_name: 'Ci::Pipeline', + inverse_of: :project has_many :stages, class_name: 'Ci::Stage', inverse_of: :project # Ci::Build objects store data on the file system such as artifact files and @@ -280,6 +301,8 @@ class Project < ActiveRecord::Base delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team delegate :add_master, to: :team # @deprecated delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings + delegate :group_clusters_enabled?, to: :group, allow_nil: true + delegate :root_ancestor, to: :namespace, allow_nil: true # Validations validates :creator, presence: true, on: :create @@ -316,6 +339,7 @@ class Project < ActiveRecord::Base presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } validates :variables, variable_duplicates: { scope: :environment_scope } + validates :bfg_object_map, file_size: { maximum: :max_attachment_size } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -372,9 +396,16 @@ class Project < ActiveRecord::Base .where(project_ci_cd_settings: { group_runners_enabled: true }) end + scope :missing_kubernetes_namespace, -> (kubernetes_namespaces) do + subquery = kubernetes_namespaces.select('1').where('clusters_kubernetes_namespaces.project_id = projects.id') + + where('NOT EXISTS (?)', subquery) + end + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } - chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600 + chronic_duration_attr :build_timeout_human_readable, :build_timeout, + default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted' validates :build_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 10.minutes, @@ -382,6 +413,9 @@ class Project < ActiveRecord::Base only_integer: true, message: 'needs to be beetween 10 minutes and 1 month' } + # Used by Projects::CleanupService to hold a map of rewritten object IDs + mount_uploader :bfg_object_map, AttachmentUploader + # Returns a project, if it is not about to be removed. # # id - The ID of the project to retrieve. @@ -451,8 +485,8 @@ class Project < ActiveRecord::Base scope :excluding_project, ->(project) { where.not(id: project) } - scope :joins_import_state, -> { joins("LEFT JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } - scope :import_started, -> { joins_import_state.where("import_state.status = 'started' OR projects.import_status = 'started'") } + # We require an alias to the project_mirror_data_table in order to use import_state in our queries + scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } scope :for_group, -> (group) { where(group: group) } class << self @@ -535,11 +569,13 @@ class Project < ActiveRecord::Base # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned - def ancestors_upto(top = nil) + def ancestors_upto(top = nil, hierarchy_order: nil) Gitlab::GroupHierarchy.new(Group.where(id: namespace_id)) - .base_and_ancestors(upto: top) + .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) end + alias_method :ancestors, :ancestors_upto + def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -610,7 +646,7 @@ class Project < ActiveRecord::Base # ref can't be HEAD, can only be branch/tag name or SHA def latest_successful_builds_for(ref = default_branch) - latest_pipeline = pipelines.latest_successful_for(ref) + latest_pipeline = ci_pipelines.latest_successful_for(ref) if latest_pipeline latest_pipeline.builds.latest.with_artifacts_archive @@ -628,6 +664,14 @@ class Project < ActiveRecord::Base id && persisted? end + def import_status + import_state&.status || 'none' + end + + def human_import_status_name + import_state&.human_status_name || 'none' + end + def add_import_job job_id = if forked? @@ -659,7 +703,7 @@ class Project < ActiveRecord::Base ProjectCacheWorker.perform_async(self.id) end - update(import_error: nil) + import_state.update(last_error: nil) remove_import_data end @@ -721,130 +765,6 @@ class Project < ActiveRecord::Base import_url.present? end - def imported? - import_finished? - end - - def import_in_progress? - import_started? || import_scheduled? - end - - def import_state_args - { - status: self[:import_status], - jid: self[:import_jid], - last_error: self[:import_error] - } - end - - def ensure_import_state(force: false) - return if !force && (self[:import_status] == 'none' || self[:import_status].nil?) - return unless import_state.nil? - - if persisted? - create_import_state(import_state_args) - - update_column(:import_status, 'none') - else - build_import_state(import_state_args) - - self[:import_status] = 'none' - end - end - - def human_import_status_name - ensure_import_state - - import_state.human_status_name - end - - def import_schedule - ensure_import_state(force: true) - - import_state.schedule - end - - def force_import_start - ensure_import_state(force: true) - - import_state.force_start - end - - def import_start - ensure_import_state(force: true) - - import_state.start - end - - def import_fail - ensure_import_state(force: true) - - import_state.fail_op - end - - def import_finish - ensure_import_state(force: true) - - import_state.finish - end - - def import_jid=(new_jid) - ensure_import_state(force: true) - - import_state.jid = new_jid - end - - def import_jid - ensure_import_state - - import_state&.jid - end - - def import_error=(new_error) - ensure_import_state(force: true) - - import_state.last_error = new_error - end - - def import_error - ensure_import_state - - import_state&.last_error - end - - def import_status=(new_status) - ensure_import_state(force: true) - - import_state.status = new_status - end - - def import_status - ensure_import_state - - import_state&.status || 'none' - end - - def no_import? - import_status == 'none' - end - - def import_started? - # import? does SQL work so only run it if it looks like there's an import running - import_status == 'started' && import? - end - - def import_scheduled? - import_status == 'scheduled' - end - - def import_failed? - import_status == 'failed' - end - - def import_finished? - import_status == 'finished' - end - def safe_import_url Gitlab::UrlSanitizer.new(import_url).masked_url end @@ -985,9 +905,9 @@ class Project < ActiveRecord::Base end def readme_url - readme = repository.readme - if readme - Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme.path)) + readme_path = repository.readme_path + if readme_path + Gitlab::Routing.url_helpers.project_blob_url(self, File.join(default_branch, readme_path)) end end @@ -1166,6 +1086,12 @@ class Project < ActiveRecord::Base path end + def all_clusters + group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } ) + + Clusters::Cluster.from_union([clusters, group_clusters]) + end + def items_for(entity) case entity when 'issue' then @@ -1246,6 +1172,11 @@ class Project < ActiveRecord::Base "#{web_url}.git" end + # Is overriden in EE + def lfs_http_url_to_repo(_) + http_url_to_repo + end + def forked? fork_network && fork_network.root_project != self end @@ -1313,6 +1244,13 @@ class Project < ActiveRecord::Base false end + def track_project_repository + return unless hashed_storage?(:repository) + + project_repo = project_repository || build_project_repository + project_repo.update!(shard_name: repository_storage, disk_path: disk_path) + end + def create_repository(force: false) # Forked import is handled asynchronously return if forked? && !force @@ -1469,7 +1407,7 @@ class Project < ActiveRecord::Base return unless sha - pipelines.order(id: :desc).find_by(sha: sha, ref: ref) + ci_pipelines.order(id: :desc).find_by(sha: sha, ref: ref) end def latest_successful_pipeline_for_default_branch @@ -1478,12 +1416,12 @@ class Project < ActiveRecord::Base end @latest_successful_pipeline_for_default_branch = - pipelines.latest_successful_for(default_branch) + ci_pipelines.latest_successful_for(default_branch) end def latest_successful_pipeline_for(ref = nil) if ref && ref != default_branch - pipelines.latest_successful_for(ref) + ci_pipelines.latest_successful_for(ref) else latest_successful_pipeline_for_default_branch end @@ -1643,8 +1581,8 @@ class Project < ActiveRecord::Base def after_import repository.after_import wiki.repository.after_import - import_finish - remove_import_jid + import_state.finish + import_state.remove_jid update_project_counter_caches after_create_default_branch refresh_markdown_cache! @@ -1684,32 +1622,11 @@ class Project < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass - def remove_import_jid - return unless import_jid - - Gitlab::SidekiqStatus.unset(import_jid) - - import_state.update_column(:jid, nil) - end - # Lazy loading of the `pipeline_status` attribute def pipeline_status @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) end - def mark_import_as_failed(error_message) - original_errors = errors.dup - sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) - - import_fail - - import_state.update_column(:last_error, sanitized_message) - rescue ActiveRecord::ActiveRecordError => e - Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}") - ensure - @errors = original_errors - end - 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) @@ -1986,17 +1903,6 @@ class Project < ActiveRecord::Base Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki)) end - # Refreshes the expiration time of the associated import job ID. - # - # This method can be used by asynchronous importers to refresh the status, - # preventing the StuckImportJobsWorker from marking the import as failed. - def refresh_import_jid_expiration - return unless import_jid - - Gitlab::SidekiqStatus - .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) - end - def badges return project_badges unless group @@ -2071,6 +1977,10 @@ class Project < ActiveRecord::Base Ability.allowed?(user, :read_project_snippet, self) end + def max_attachment_size + Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i + end + private def use_hashed_storage diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 7126bb66d80..488f0cb5971 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -69,4 +69,33 @@ class ProjectImportState < ActiveRecord::Base ensure @errors = original_errors end + + alias_method :no_import?, :none? + + def in_progress? + scheduled? || started? + end + + def started? + # import? does SQL work so only run it if it looks like there's an import running + status == 'started' && project.import? + end + + def remove_jid + return unless jid + + Gitlab::SidekiqStatus.unset(jid) + + update_column(:jid, nil) + end + + # Refreshes the expiration time of the associated import job ID. + # + # This method can be used by asynchronous importers to refresh the status, + # preventing the StuckImportJobsWorker from marking the import as failed. + def refresh_jid_expiration + return unless jid + + Gitlab::SidekiqStatus.set(jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) + end end diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb new file mode 100644 index 00000000000..38913f3f2f5 --- /dev/null +++ b/app/models/project_repository.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectRepository < ActiveRecord::Base + include Shardable + + belongs_to :project, inverse_of: :project_repository + + class << self + def find_project(disk_path) + find_by(disk_path: disk_path)&.project + end + end +end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index c52a531e5fe..b801fd84a07 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -110,14 +110,12 @@ class KubernetesService < DeploymentService # Clusters::Platforms::Kubernetes, it won't be used on this method # as it's only needed for Clusters::Cluster. def predefined_variables(project:) - config = YAML.dump(kubeconfig) - Gitlab::Ci::Variables::Collection.new.tap do |variables| variables .append(key: 'KUBE_URL', value: api_url) .append(key: 'KUBE_TOKEN', value: token, public: false) .append(key: 'KUBE_NAMESPACE', value: actual_namespace) - .append(key: 'KUBECONFIG', value: config, public: false, file: true) + .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) if ca_pem.present? variables diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 6f39a5e6e83..d60a6a7efa3 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -38,11 +38,11 @@ class PipelinesEmailService < Service end def can_test? - project.pipelines.any? + project.ci_pipelines.any? end def test_data(project, user) - data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last) + data = Gitlab::DataBuilder::Pipeline.build(project.ci_pipelines.last) data[:user] = user.hook_attrs data end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 211e5c3fcbf..60cb2d380d5 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -71,7 +71,7 @@ class PrometheusService < MonitoringService end def prometheus_client - RestClient::Resource.new(api_url) if api_url && manual_configuration? && active? + RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active? end def prometheus_available? diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index a3415a4a14c..b7b4d0f1be9 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -15,8 +15,6 @@ class RemoteMirror < ActiveRecord::Base insecure_mode: true, algorithm: 'aes-256-cbc' - default_value_for :only_protected_branches, true - belongs_to :project, inverse_of: :remote_mirrors validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } diff --git a/app/models/repository.rb b/app/models/repository.rb index 427dac99b79..35dd120856d 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -35,7 +35,7 @@ class Repository # # For example, for entry `:commit_count` there's a method called `commit_count` which # stores its data in the `commit_count` cache key. - CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide + CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref has_visible_content? @@ -48,7 +48,7 @@ class Repository # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to # the corresponding methods to call for refreshing caches. METHOD_CACHES_FOR_FILE_TYPES = { - readme: :rendered_readme, + readme: %i(rendered_readme readme_path), changelog: :changelog, license: %i(license_blob license_key license), contributing: :contribution_guide, @@ -591,6 +591,11 @@ class Repository head_tree&.readme end + def readme_path + readme&.path + end + cache_method :readme_path + def rendered_readme return unless readme diff --git a/app/models/shard.rb b/app/models/shard.rb index 2e75bc91df0..e39d4232486 100644 --- a/app/models/shard.rb +++ b/app/models/shard.rb @@ -18,7 +18,9 @@ class Shard < ActiveRecord::Base end def self.by_name(name) - find_or_create_by(name: name) + transaction(requires_new: true) do + find_or_create_by(name: name) + end rescue ActiveRecord::RecordNotUnique retry end diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb index 90710f73fd3..911fb7e9ce9 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed_project.rb @@ -5,17 +5,19 @@ module Storage attr_accessor :project delegate :gitlab_shell, :repository_storage, to: :project - ROOT_PATH_PREFIX = '@hashed'.freeze + REPOSITORY_PATH_PREFIX = '@hashed' + POOL_PATH_PREFIX = '@pools' - def initialize(project) + def initialize(project, prefix: REPOSITORY_PATH_PREFIX) @project = project + @prefix = prefix end # Base directory # # @return [String] directory where repository is stored def base_dir - "#{ROOT_PATH_PREFIX}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash + "#{@prefix}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash end # Disk path is used to build repository and project's wiki path on disk diff --git a/app/models/upload.rb b/app/models/upload.rb index e01e9c6a4f0..20860f14b83 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -25,6 +25,25 @@ class Upload < ActiveRecord::Base Digest::SHA256.file(path).hexdigest end + class << self + ## + # FastDestroyAll concerns + def begin_fast_destroy + { + Uploads::Local => Uploads::Local.new.keys(with_files_stored_locally), + Uploads::Fog => Uploads::Fog.new.keys(with_files_stored_remotely) + } + end + + ## + # FastDestroyAll concerns + def finalize_fast_destroy(keys) + keys.each do |store_class, paths| + store_class.new.delete_keys_async(paths) + end + end + end + def absolute_path raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? return path unless relative_path? diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb new file mode 100644 index 00000000000..f9814159958 --- /dev/null +++ b/app/models/uploads/base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Uploads + class Base + BATCH_SIZE = 100 + + attr_reader :logger + + def initialize(logger: nil) + @logger ||= Rails.logger + end + + def delete_keys_async(keys_to_delete) + keys_to_delete.each_slice(BATCH_SIZE) do |batch| + DeleteStoredFilesWorker.perform_async(self.class, batch) + end + end + end +end diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb new file mode 100644 index 00000000000..b44e273e9ab --- /dev/null +++ b/app/models/uploads/fog.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Uploads + class Fog < Base + include ::Gitlab::Utils::StrongMemoize + + def available? + object_store.enabled + end + + def keys(relation) + return [] unless available? + + relation.pluck(:path) + end + + def delete_keys(keys) + keys.each do |key| + connection.delete_object(bucket_name, key) + end + end + + private + + def object_store + Gitlab.config.uploads.object_store + end + + def bucket_name + return unless available? + + object_store.remote_directory + end + + def connection + return unless available? + + strong_memoize(:connection) do + ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) + end + end + end +end diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb new file mode 100644 index 00000000000..2901c33c359 --- /dev/null +++ b/app/models/uploads/local.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Uploads + class Local < Base + def keys(relation) + relation.includes(:model).find_each.map(&:absolute_path) + end + + def delete_keys(keys) + keys.each do |path| + delete_file(path) + end + end + + private + + def delete_file(path) + unless exists?(path) + logger.warn("File '#{path}' doesn't exist, skipping") + return + end + + unless in_uploads?(path) + message = "Path '#{path}' is not in uploads dir, skipping" + logger.warn(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir }) + return + end + + FileUtils.rm(path) + delete_dir!(File.dirname(path)) + end + + def exists?(path) + path.present? && File.exist?(path) + end + + def in_uploads?(path) + path.start_with?(storage_dir) + end + + def delete_dir!(path) + Dir.rmdir(path) + rescue Errno::ENOENT + # Ignore: path does not exist + rescue Errno::ENOTDIR + # Ignore: path is not a dir + rescue Errno::ENOTEMPTY, Errno::EEXIST + # Ignore: dir is not empty + end + + def storage_dir + @storage_dir ||= File.realpath(Gitlab.config.uploads.storage_path) + end + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 7769c3d71c0..b1d6d461928 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -85,6 +85,12 @@ class WikiPage alias_method :to_param, :slug + def human_title + return 'Home' if title == 'home' + + title + end + # The formatted title of this page. def title if @attributes[:title] diff --git a/app/policies/commit_policy.rb b/app/policies/commit_policy.rb index 67e9bc12804..4d4f0ba9267 100644 --- a/app/policies/commit_policy.rb +++ b/app/policies/commit_policy.rb @@ -2,4 +2,6 @@ class CommitPolicy < BasePolicy delegate { @subject.project } + + rule { can?(:download_code) }.enable :read_commit end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index bbc2b48b856..f22843b6463 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -9,8 +9,17 @@ class NotePolicy < BasePolicy condition(:editable, scope: :subject) { @subject.editable? } + condition(:can_read_noteable) { can?(:"read_#{@subject.to_ability_name}") } + rule { ~editable }.prevent :admin_note + # If user can't read the issue/MR/etc then they should not be allowed to do anything to their own notes + rule { ~can_read_noteable }.policy do + prevent :read_note + prevent :admin_note + prevent :resolve_note + end + rule { is_author }.policy do enable :read_note enable :admin_note diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index d963c188559..ef6bbc0d109 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -31,6 +31,6 @@ class GroupClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end end diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 2497bea4aff..9e9b6973b8e 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -7,6 +7,14 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated member.class.access_level_roles end + def valid_level_roles + return access_level_roles unless member.highest_group_member + + access_level_roles.reject do |_name, level| + member.highest_group_member.access_level > level + end + end + def can_resend_invite? invite? && can?(current_user, admin_member_permission, source) diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index d61124fa787..9bd64ea217e 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -6,27 +6,27 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated include GitlabRoutingHelper include StorageHelper include TreeHelper + include IconsHelper include ChecksCollaboration include Gitlab::Utils::StrongMemoize presents :project - AnchorData = Struct.new(:enabled, :label, :link, :class_modifier) + AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) MAX_TAGS_TO_SHOW = 3 + def statistic_icon(icon_name = 'plus-square-o') + sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') + end + def statistics_anchors(show_auto_devops_callout:) [ - readme_anchor_data, - changelog_anchor_data, - contribution_guide_anchor_data, - files_anchor_data, + license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, - gitlab_ci_anchor_data, - autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), - kubernetes_cluster_anchor_data - ].compact.select { |item| item.enabled } + files_anchor_data + ].compact.select(&:is_link) end def statistics_buttons(show_auto_devops_callout:) @@ -37,27 +37,28 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), kubernetes_cluster_anchor_data, gitlab_ci_anchor_data - ].compact.reject { |item| item.enabled } + ].compact.reject(&:is_link) end def empty_repo_statistics_anchors [ - files_anchor_data, + license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, - autodevops_anchor_data, - kubernetes_cluster_anchor_data - ].compact.select { |item| item.enabled } + files_anchor_data + ].compact.select { |item| item.is_link } end def empty_repo_statistics_buttons [ new_file_anchor_data, readme_anchor_data, + changelog_anchor_data, + contribution_guide_anchor_data, autodevops_anchor_data, kubernetes_cluster_anchor_data - ].compact.reject { |item| item.enabled } + ].compact.reject { |item| item.is_link } end def default_view @@ -113,7 +114,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_contribution_guide_path - add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') + add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add CONTRIBUTING') end def add_ci_yml_path @@ -149,32 +150,52 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def files_anchor_data AnchorData.new(true, - _('Files (%{human_size})') % { human_size: storage_counter(statistics.total_repository_size) }, + statistic_icon('doc-code') + + _('%{strong_start}%{human_size}%{strong_end} Files').html_safe % { + human_size: storage_counter(statistics.total_repository_size), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_tree_path(project)) end def commits_anchor_data AnchorData.new(true, - n_('Commit (%{commit_count})', 'Commits (%{commit_count})', statistics.commit_count) % { commit_count: number_with_delimiter(statistics.commit_count) }, + statistic_icon('commit') + + n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % { + commit_count: number_with_delimiter(statistics.commit_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_commits_path(project, repository.root_ref)) end def branches_anchor_data AnchorData.new(true, - n_('Branch (%{branch_count})', 'Branches (%{branch_count})', repository.branch_count) % { branch_count: number_with_delimiter(repository.branch_count) }, + statistic_icon('branch') + + n_('%{strong_start}%{branch_count}%{strong_end} Branch', '%{strong_start}%{branch_count}%{strong_end} Branches', repository.branch_count).html_safe % { + branch_count: number_with_delimiter(repository.branch_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_branches_path(project)) end def tags_anchor_data AnchorData.new(true, - n_('Tag (%{tag_count})', 'Tags (%{tag_count})', repository.tag_count) % { tag_count: number_with_delimiter(repository.tag_count) }, + statistic_icon('label') + + n_('%{strong_start}%{tag_count}%{strong_end} Tag', '%{strong_start}%{tag_count}%{strong_end} Tags', repository.tag_count).html_safe % { + tag_count: number_with_delimiter(repository.tag_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, empty_repo? ? nil : project_tags_path(project)) end def new_file_anchor_data if current_user && can_current_user_push_to_default_branch? AnchorData.new(false, - _('New file'), + statistic_icon + _('New file'), project_new_blob_path(project, default_branch || 'master'), 'success') end @@ -183,40 +204,45 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def readme_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.readme.nil? AnchorData.new(false, - _('Add Readme'), + statistic_icon + _('Add README'), add_readme_path) elsif repository.readme - AnchorData.new(true, - _('Readme'), - default_view != 'readme' ? readme_path : '#readme') + AnchorData.new(false, + statistic_icon('doc-text') + _('README'), + default_view != 'readme' ? readme_path : '#readme', + 'default', + 'doc-text') end end def changelog_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank? AnchorData.new(false, - _('Add Changelog'), + statistic_icon + _('Add CHANGELOG'), add_changelog_path) elsif repository.changelog.present? - AnchorData.new(true, - _('Changelog'), - changelog_path) + AnchorData.new(false, + statistic_icon('doc-text') + _('CHANGELOG'), + changelog_path, + 'default') end end def license_anchor_data + icon = statistic_icon('scale') + if repository.license_blob.present? AnchorData.new(true, - license_short_name, + icon + content_tag(:strong, license_short_name, class: 'project-stat-value'), license_path) else if current_user && can_current_user_push_to_default_branch? - AnchorData.new(false, - _('Add license'), + AnchorData.new(true, + content_tag(:span, icon + _('Add license'), class: 'add-license-link d-flex'), add_license_path) else - AnchorData.new(false, - _('No license. All rights reserved'), + AnchorData.new(true, + icon + content_tag(:strong, _('No license. All rights reserved'), class: 'project-stat-value'), nil) end end @@ -225,22 +251,29 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def contribution_guide_anchor_data if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank? AnchorData.new(false, - _('Add Contribution guide'), + statistic_icon + _('Add CONTRIBUTING'), add_contribution_guide_path) elsif repository.contribution_guide.present? - AnchorData.new(true, - _('Contribution guide'), + AnchorData.new(false, + statistic_icon('doc-text') + _('CONTRIBUTING'), contribution_guide_path) end end def autodevops_anchor_data(show_auto_devops_callout: false) if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout - AnchorData.new(auto_devops_enabled?, - auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'), - project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + if auto_devops_enabled? + AnchorData.new(false, + statistic_icon('doc-text') + _('Auto DevOps enabled'), + project_settings_ci_cd_path(project, anchor: 'autodevops-settings'), + 'default') + else + AnchorData.new(false, + statistic_icon + _('Enable Auto DevOps'), + project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + end elsif auto_devops_enabled? - AnchorData.new(true, + AnchorData.new(false, _('Auto DevOps enabled'), nil) end @@ -248,27 +281,32 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def kubernetes_cluster_anchor_data if current_user && can?(current_user, :create_cluster, project) - cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) if clusters.empty? - cluster_link = new_project_cluster_path(project) - end + AnchorData.new(false, + statistic_icon + _('Add Kubernetes cluster'), + new_project_cluster_path(project)) + else + cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) - AnchorData.new(!clusters.empty?, - clusters.empty? ? _('Add Kubernetes cluster') : _('Kubernetes configured'), - cluster_link) + AnchorData.new(false, + _('Kubernetes configured'), + cluster_link, + 'default') + end end end def gitlab_ci_anchor_data if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? AnchorData.new(false, - _('Set up CI/CD'), + statistic_icon + _('Set up CI/CD'), add_ci_yml_path) elsif repository.gitlab_ci_yml.present? - AnchorData.new(true, - _('CI/CD configuration'), - ci_configuration_path) + AnchorData.new(false, + statistic_icon('doc-text') + _('CI/CD configuration'), + ci_configuration_path, + 'default') end end diff --git a/app/serializers/README.md b/app/serializers/README.md index 0337f88db5f..bb94745b0b5 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -180,7 +180,7 @@ def index render json: MyResourceSerializer .new(current_user: @current_user) .represent_details(@project.resources) - nd + end end ``` @@ -196,7 +196,7 @@ def index .represent_details(@project.resources), count: @project.resources.count } - nd + end end ``` diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb new file mode 100644 index 00000000000..06a8db78476 --- /dev/null +++ b/app/serializers/diff_file_base_entity.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class DiffFileBaseEntity < Grape::Entity + include RequestAwareEntity + include BlobHelper + include SubmoduleHelper + include DiffHelper + include TreeHelper + include ChecksCollaboration + include Gitlab::Utils::StrongMemoize + + expose :content_sha + expose :submodule?, as: :submodule + + expose :submodule_link do |diff_file| + memoized_submodule_links(diff_file).first + end + + expose :submodule_tree_url do |diff_file| + memoized_submodule_links(diff_file).last + end + + expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} + + next unless merge_request.source_project + + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end + + expose :old_path_html do |diff_file| + old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + old_path + end + + expose :new_path_html do |diff_file| + _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + new_path + end + + expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].formatted_external_url + end + + expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) + end + + expose :blob, using: BlobEntity + + expose :can_modify_blob do |diff_file| + merge_request = options[:merge_request] + + next unless diff_file.blob + + if merge_request&.source_project && current_user + can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) + else + false + end + end + + expose :file_hash do |diff_file| + Digest::SHA1.hexdigest(diff_file.file_path) + end + + expose :file_path + expose :old_path + expose :new_path + expose :new_file?, as: :new_file + expose :collapsed?, as: :collapsed + expose :text?, as: :text + expose :diff_refs + expose :stored_externally?, as: :stored_externally + expose :external_storage + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file + expose :mode_changed?, as: :mode_changed + expose :a_mode + expose :b_mode + + private + + def memoized_submodule_links(diff_file) + strong_memoize(:submodule_links) do + if diff_file.submodule? + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) + else + [] + end + end + end + + def current_user + request.current_user + end +end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 63ea8e8f95f..f0881829efd 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -1,63 +1,12 @@ # frozen_string_literal: true -class DiffFileEntity < Grape::Entity - include RequestAwareEntity +class DiffFileEntity < DiffFileBaseEntity include CommitsHelper - include DiffHelper - include SubmoduleHelper - include BlobHelper include IconsHelper - include TreeHelper - include ChecksCollaboration - include Gitlab::Utils::StrongMemoize - expose :submodule?, as: :submodule - - expose :submodule_link do |diff_file| - memoized_submodule_links(diff_file).first - end - - expose :submodule_tree_url do |diff_file| - memoized_submodule_links(diff_file).last - end - - expose :blob, using: BlobEntity - - expose :can_modify_blob do |diff_file| - merge_request = options[:merge_request] - - next unless diff_file.blob - - if merge_request&.source_project && current_user - can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) - else - false - end - end - - expose :file_hash do |diff_file| - Digest::SHA1.hexdigest(diff_file.file_path) - end - - expose :file_path expose :too_large?, as: :too_large - expose :collapsed?, as: :collapsed - expose :new_file?, as: :new_file - - expose :deleted_file?, as: :deleted_file - expose :renamed_file?, as: :renamed_file - expose :old_path - expose :new_path - expose :mode_changed?, as: :mode_changed - expose :a_mode - expose :b_mode - expose :text?, as: :text expose :added_lines expose :removed_lines - expose :diff_refs - expose :content_sha - expose :stored_externally?, as: :stored_externally - expose :external_storage expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -75,36 +24,6 @@ class DiffFileEntity < Grape::Entity ) end - expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].formatted_external_url - end - - expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) - end - - expose :old_path_html do |diff_file| - old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - old_path - end - - expose :new_path_html do |diff_file| - _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - new_path - end - - expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| - merge_request = options[:merge_request] - - options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} - - next unless merge_request.source_project - - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) - end - expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -145,18 +64,4 @@ class DiffFileEntity < Grape::Entity # Used for parallel diffs expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } - - def current_user - request.current_user - end - - def memoized_submodule_links(diff_file) - strong_memoize(:submodule_links) do - if diff_file.submodule? - submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) - else - [] - end - end - end end diff --git a/app/serializers/discussion_diff_file_entity.rb b/app/serializers/discussion_diff_file_entity.rb new file mode 100644 index 00000000000..419e7edf94f --- /dev/null +++ b/app/serializers/discussion_diff_file_entity.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DiscussionDiffFileEntity < DiffFileBaseEntity +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index b6786a0d597..b2d9d52bd22 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -36,7 +36,7 @@ class DiscussionEntity < Grape::Entity new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end - expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? } + expose :diff_file, using: DiscussionDiffFileEntity, if: -> (d, _) { d.diff_discussion? } expose :diff_discussion?, as: :diff_discussion @@ -46,19 +46,6 @@ class DiscussionEntity < Grape::Entity expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } - expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion| - diff_file = discussion.diff_file - partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' - options[:context].render_to_string( - partial: "projects/diffs/#{partial}", - locals: { diff_file: diff_file, - position: discussion.position.to_json, - click_to_comment: false }, - layout: false, - formats: [:html] - ) - end - expose :for_commit?, as: :for_commit expose :commit_id diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb new file mode 100644 index 00000000000..58ab804a3c8 --- /dev/null +++ b/app/serializers/issue_board_entity.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class IssueBoardEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :iid + expose :title + + expose :confidential + expose :due_date + expose :project_id + expose :relative_position + + expose :project do |issue| + API::Entities::Project.represent issue.project, only: [:id, :path] + end + + expose :milestone, expose_nil: false do |issue| + API::Entities::Project.represent issue.milestone, only: [:id, :title] + end + + expose :assignees do |issue| + API::Entities::UserBasic.represent issue.assignees, only: [:id, :name, :username, :avatar_url] + end + + expose :labels do |issue| + LabelEntity.represent issue.labels, project: issue.project, only: [:id, :title, :description, :color, :priority, :text_color] + end + + expose :reference_path, if: -> (issue) { issue.project } do |issue, options| + options[:include_full_project_path] ? issue.to_reference(full: true) : issue.to_reference + end + + expose :real_path, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue) + end + + expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar') + end + + expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue| + toggle_subscription_project_issue_path(issue.project, issue) + end + + expose :assignable_labels_endpoint, if: -> (issue) { issue.project } do |issue| + project_labels_path(issue.project, format: :json, include_ancestor_groups: true) + end +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 37cf5e28396..d66f0a5acb7 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -4,15 +4,17 @@ class IssueSerializer < BaseSerializer # This overrided method takes care of which entity should be used # to serialize the `issue` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. - def represent(merge_request, opts = {}) + def represent(issue, opts = {}) entity = case opts[:serializer] when 'sidebar' IssueSidebarEntity + when 'board' + IssueBoardEntity else IssueEntity end - super(merge_request, opts, entity) + super(issue, opts, entity) end end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 98743d62b50..5082245dda9 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -12,4 +12,8 @@ class LabelEntity < Grape::Entity expose :text_color expose :created_at expose :updated_at + + expose :priority, if: -> (*) { options.key?(:project) } do |label| + label.priority(options[:project]) + end end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index aef838409e0..c9669e59199 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -23,6 +23,7 @@ class PipelineEntity < Grape::Entity expose :latest?, as: :latest expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops + expose :merge_request?, as: :merge_request expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable @@ -48,6 +49,7 @@ class PipelineEntity < Grape::Entity expose :tag?, as: :tag expose :branch?, as: :branch + expose :merge_request?, as: :merge_request end expose :commit, using: CommitEntity diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb new file mode 100644 index 00000000000..4f1f62d145b --- /dev/null +++ b/app/serializers/projects/serverless/service_entity.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceEntity < Grape::Entity + include RequestAwareEntity + + expose :name do |service| + service.dig('metadata', 'name') + end + + expose :namespace do |service| + service.dig('metadata', 'namespace') + end + + expose :created_at do |service| + service.dig('metadata', 'creationTimestamp') + end + + expose :url do |service| + "http://#{service.dig('status', 'domain')}" + end + + expose :description do |service| + service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') + end + + expose :image do |service| + service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') + end + end + end +end diff --git a/app/serializers/projects/serverless/service_serializer.rb b/app/serializers/projects/serverless/service_serializer.rb new file mode 100644 index 00000000000..adfd48a8c7d --- /dev/null +++ b/app/serializers/projects/serverless/service_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceSerializer < BaseSerializer + entity Projects::Serverless::ServiceEntity + end + end +end diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index 2a337918d21..40aa9250885 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -6,6 +6,7 @@ class AccessTokenValidationService EXPIRED = :expired REVOKED = :revoked INSUFFICIENT_SCOPE = :insufficient_scope + IMPERSONATION_DISABLED = :impersonation_disabled attr_reader :token, :request @@ -24,6 +25,11 @@ class AccessTokenValidationService elsif !self.include_any_scope?(scopes) return INSUFFICIENT_SCOPE + elsif token.respond_to?(:impersonation) && + token.impersonation && + !Gitlab.config.gitlab.impersonation_enabled + return IMPERSONATION_DISABLED + else return VALID end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb new file mode 100644 index 00000000000..a1dd00721b5 --- /dev/null +++ b/app/services/ci/archive_trace_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ci + class ArchiveTraceService + def execute(job) + job.trace.archive! + rescue ::Gitlab::Ci::Trace::AlreadyArchivedError + # It's already archived, thus we can safely ignore this exception. + rescue => e + # Tracks this error with application logs, Sentry, and Prometheus. + # If `archive!` keeps failing for over a week, that could incur data loss. + # (See more https://docs.gitlab.com/ee/administration/job_traces.html#new-live-trace-architecture) + # In order to avoid interrupting the system, we do not raise an exception here. + archive_error(e, job) + end + + private + + def failed_archive_counter + @failed_archive_counter ||= + Gitlab::Metrics.counter(:job_trace_archive_failed_total, + "Counter of failed attempts of trace archiving") + end + + def archive_error(error, job) + failed_archive_counter.increment + Rails.logger.error "Failed to archive trace. id: #{job.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/51502', + extra: { job_id: job.id }) + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 92a8438ab2f..19b5552887f 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -4,6 +4,8 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline + CreateError = Class.new(StandardError) + SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, @@ -12,7 +14,7 @@ module Ci 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) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -23,6 +25,7 @@ module Ci before_sha: params[:before], trigger_request: trigger_request, schedule: schedule, + merge_request: merge_request, ignore_skip_ci: ignore_skip_ci, save_incompleted: save_on_errors, seeds_block: block, @@ -47,6 +50,14 @@ module Ci pipeline end + def execute!(*args, &block) + execute(*args, &block).tap do |pipeline| + unless pipeline.persisted? + raise CreateError, pipeline.errors.full_messages.join(',') + end + end + end + private def commit @@ -67,7 +78,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def auto_cancelable_pipelines - project.pipelines + project.ci_pipelines .where(ref: pipeline.ref) .where.not(id: pipeline.id) .where.not(sha: project.commit(pipeline.ref).try(:id)) diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 3df43657fa0..e029323774c 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -12,7 +12,8 @@ module Clusters create_gitlab_service_account! configure_kubernetes cluster.save! - configure_project_service_account + + ClusterPlatformConfigureWorker.perform_async(cluster.id) rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") @@ -25,7 +26,7 @@ module Clusters private def create_gitlab_service_account! - Clusters::Gcp::Kubernetes::CreateServiceAccountService.gitlab_creator( + Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator( kube_client, rbac: create_rbac_cluster? ).execute @@ -55,15 +56,6 @@ module Clusters ).execute end - def configure_project_service_account - kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project) - - Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( - cluster: cluster, - kubernetes_namespace: kubernetes_namespace - ).execute - end - def authorization_type create_rbac_cluster? ? 'rbac' : 'abac' end diff --git a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb index b31426556f6..806f320381d 100644 --- a/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb +++ b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb @@ -27,7 +27,7 @@ module Clusters end def create_project_service_account - Clusters::Gcp::Kubernetes::CreateServiceAccountService.namespace_creator( + Clusters::Gcp::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator( platform.kubeclient, service_account_name: kubernetes_namespace.service_account_name, service_account_namespace: kubernetes_namespace.namespace, diff --git a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb index dfc4bf7a358..49e766cbf13 100644 --- a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb +++ b/app/services/clusters/gcp/kubernetes/create_or_update_service_account_service.rb @@ -3,7 +3,7 @@ module Clusters module Gcp module Kubernetes - class CreateServiceAccountService + class CreateOrUpdateServiceAccountService def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) @kubeclient = kubeclient @service_account_name = service_account_name @@ -38,8 +38,9 @@ module Clusters def execute ensure_project_namespace_exists if namespace_creator - kubeclient.create_service_account(service_account_resource) - kubeclient.create_secret(service_account_token_resource) + + kubeclient.create_or_update_service_account(service_account_resource) + kubeclient.create_or_update_secret(service_account_token_resource) create_role_or_cluster_role_binding if rbac end @@ -56,9 +57,9 @@ module Clusters def create_role_or_cluster_role_binding if namespace_creator - kubeclient.create_role_binding(role_binding_resource) + kubeclient.create_or_update_role_binding(role_binding_resource) else - kubeclient.create_cluster_role_binding(cluster_role_binding_resource) + kubeclient.create_or_update_cluster_role_binding(cluster_role_binding_resource) end end diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb new file mode 100644 index 00000000000..7c82b98a33f --- /dev/null +++ b/app/services/clusters/refresh_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Clusters + class RefreshService + def self.create_or_update_namespaces_for_cluster(cluster) + projects_with_missing_kubernetes_namespaces_for_cluster(cluster).each do |project| + create_or_update_namespace(cluster, project) + end + end + + def self.create_or_update_namespaces_for_project(project) + clusters_with_missing_kubernetes_namespaces_for_project(project).each do |cluster| + create_or_update_namespace(cluster, project) + end + end + + def self.projects_with_missing_kubernetes_namespaces_for_cluster(cluster) + cluster.all_projects.missing_kubernetes_namespace(cluster.kubernetes_namespaces) + end + + private_class_method :projects_with_missing_kubernetes_namespaces_for_cluster + + def self.clusters_with_missing_kubernetes_namespaces_for_project(project) + project.all_clusters.missing_kubernetes_namespace(project.kubernetes_namespaces) + end + + private_class_method :clusters_with_missing_kubernetes_namespaces_for_project + + def self.create_or_update_namespace(cluster, project) + kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace_for_project(project) + + ::Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( + cluster: cluster, + kubernetes_namespace: kubernetes_namespace + ).execute + end + + private_class_method :create_or_update_namespace + end +end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index c9d3ee31d82..927634c2159 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -8,6 +8,7 @@ module Files transformer = Lfs::FileTransformer.new(project, @branch_name) actions = actions_after_lfs_transformation(transformer, params[:actions]) + actions = transform_move_actions(actions) commit_actions!(actions) end @@ -26,6 +27,16 @@ module Files end end + # When moving a file, `content: nil` means "use the contents of the previous + # file", while `content: ''` means "move the file and set it to empty" + def transform_move_actions(actions) + actions.map do |action| + action[:infer_content] = true if action[:content].nil? + + action + end + end + def commit_actions!(actions) repository.multi_action( current_user, diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 28c3219b37b..fe19abf50f6 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -54,6 +54,24 @@ module MergeRequests merge_request, merge_request.project, current_user, merge_request.assignee) end + def create_merge_request_pipeline(merge_request, user) + return unless Feature.enabled?(:ci_merge_request_pipeline, + merge_request.source_project, + default_enabled: true) + + ## + # UpdateMergeRequestsWorker could be retried by an exception. + # MR pipelines should not be recreated in such case. + return if merge_request.merge_request_pipeline_exists? + + Ci::CreatePipelineService + .new(merge_request.source_project, user, ref: merge_request.source_branch) + .execute(:merge_request, + ignore_skip_ci: true, + save_on_errors: false, + merge_request: merge_request) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. # rubocop: disable CodeReuse/ActiveRecord def merge_requests_for(source_branch, mr_states: [:opened]) diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 6081a7d1de0..7bb9fa60515 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -25,6 +25,7 @@ module MergeRequests def after_create(issuable) todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) + create_merge_request_pipeline(issuable, current_user) update_merge_requests_head_pipeline(issuable) super @@ -49,18 +50,14 @@ module MergeRequests merge_request.update(head_pipeline_id: pipeline.id) if pipeline end - # rubocop: disable CodeReuse/ActiveRecord def head_pipeline_for(merge_request) return unless merge_request.source_project sha = merge_request.source_branch_sha return unless sha - pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha) - - pipelines.order(id: :desc).first + merge_request.all_pipelines(shas: sha).first end - # rubocop: enable CodeReuse/ActiveRecord def set_projects! # @project is used to determine whether the user can set the merge request's diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 5fe48da1cd6..f712b8863cd 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -58,13 +58,27 @@ module MergeRequests .preload(:latest_merge_request_diff) .where(target_branch: @push.branch_name).to_a .select(&:diff_head_commit) + .select do |merge_request| + commit_ids.include?(merge_request.diff_head_sha) && + merge_request.merge_request_diff.state != 'empty' + end + merge_requests = filter_merge_requests(merge_requests) + + return if merge_requests.empty? - merge_requests = merge_requests.select do |merge_request| - commit_ids.include?(merge_request.diff_head_sha) && - merge_request.merge_request_diff.state != 'empty' + commit_analyze_enabled = Feature.enabled?(:branch_push_merge_commit_analyze, @project, default_enabled: true) + if commit_analyze_enabled + analyzer = Gitlab::BranchPushMergeCommitAnalyzer.new( + @commits.reverse, + relevant_commit_ids: merge_requests.map(&:diff_head_sha) + ) end - filter_merge_requests(merge_requests).each do |merge_request| + merge_requests.each do |merge_request| + if commit_analyze_enabled + merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha) + end + MergeRequests::PostMergeService .new(merge_request.target_project, @current_user) .execute(merge_request) @@ -92,6 +106,7 @@ module MergeRequests end merge_request.mark_as_unchecked + create_merge_request_pipeline(merge_request, current_user) UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5904bfbf88d..e24ef7f9c87 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -466,6 +466,14 @@ class NotificationService end end + def repository_cleanup_success(project, user) + mailer.send(:repository_cleanup_success_email, project, user).deliver_later + end + + def repository_cleanup_failure(project, user, error) + mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later + end + protected def new_resource_email(target, method) diff --git a/app/services/projects/auto_devops/disable_service.rb b/app/services/projects/auto_devops/disable_service.rb index 1b578a3c5ce..6608b3da1a8 100644 --- a/app/services/projects/auto_devops/disable_service.rb +++ b/app/services/projects/auto_devops/disable_service.rb @@ -34,7 +34,7 @@ module Projects end def auto_devops_pipelines - @auto_devops_pipelines ||= project.pipelines.auto_devops_source + @auto_devops_pipelines ||= project.ci_pipelines.auto_devops_source end end end diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb new file mode 100644 index 00000000000..12103ea34b5 --- /dev/null +++ b/app/services/projects/cleanup_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Projects + # The CleanupService removes data from the project repository following a + # BFG rewrite: https://rtyley.github.io/bfg-repo-cleaner/ + # + # Before executing this service, all refs rewritten by BFG should have been + # pushed to the repository + class CleanupService < BaseService + NoUploadError = StandardError.new("Couldn't find uploaded object map") + + include Gitlab::Utils::StrongMemoize + + # Attempt to clean up the project following the push. Warning: this is + # destructive! + # + # path is the path of an upload of a BFG object map file. It contains a line + # per rewritten object, with the old and new SHAs space-separated. It can be + # used to update or remove content that references the objects that BFG has + # altered + # + # Currently, only the project repository is modified by this service, but we + # may wish to modify other data sources in the future. + def execute + apply_bfg_object_map! + + # Remove older objects that are no longer referenced + GitGarbageCollectWorker.new.perform(project.id, :gc) + + # The cache may now be inaccurate, and holding onto it could prevent + # bugs assuming the presence of some object from manifesting for some + # time. Better to feel the pain immediately. + project.repository.expire_all_method_caches + + project.bfg_object_map.remove! + end + + private + + def apply_bfg_object_map! + raise NoUploadError unless project.bfg_object_map.exists? + + project.bfg_object_map.open do |io| + repository_cleaner.apply_bfg_object_map(io) + end + end + + def repository_cleaner + @repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw) + end + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 20bfe5af7a1..d03137b63b2 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -9,7 +9,7 @@ module Projects end def execute - if @params[:template_name]&.present? + if @params[:template_name].present? return ::Projects::CreateFromTemplateService.new(current_user, params).execute end @@ -86,6 +86,8 @@ module Projects @project.create_wiki unless skip_wiki? end + @project.track_project_repository + event_service.create_project(@project, current_user) system_hook_service.execute_hooks_for(@project, :create) @@ -94,6 +96,8 @@ module Projects current_user.invalidate_personal_projects_count create_readme if @initialize_with_readme + + configure_group_clusters_for_project end # Refresh the current user's authorizations inline (so they can access the @@ -119,6 +123,10 @@ module Projects Files::CreateService.new(@project, current_user, commit_attrs).execute end + def configure_group_clusters_for_project + ClusterProjectConfigureWorker.perform_async(@project.id) + end + def skip_wiki? !@project.feature_available?(:wiki, current_user) || @skip_wiki end @@ -148,7 +156,7 @@ module Projects Rails.logger.error(log_message) if @project - @project.mark_import_as_failed(message) if @project.persisted? && @project.import? + @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import? end @project @@ -181,7 +189,7 @@ module Projects def import_schedule if @project.errors.empty? - @project.import_schedule if @project.import? && !@project.bare_repository_import? + @project.import_state.schedule if @project.import? && !@project.bare_repository_import? else fail(error: @project.errors.full_messages.join(', ')) end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index 4462d504071..f3e026ba38c 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -30,6 +30,7 @@ module Projects if result project.write_repository_config + project.track_project_repository else rollback_folder_move project.storage_version = nil diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 9d40ab166ff..9db3fd9cf17 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -54,6 +54,7 @@ module Projects end attempt_transfer_transaction + configure_group_clusters_for_project end # rubocop: enable CodeReuse/ActiveRecord @@ -162,5 +163,9 @@ module Projects @new_namespace.full_path ) end + + def configure_group_clusters_for_project + ClusterProjectConfigureWorker.perform_async(project.id) + end end end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 45e0e61e5c4..7e14ddcd017 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -49,7 +49,7 @@ module TestHooks end def pipeline_events_data - pipeline = project.pipelines.first + pipeline = project.ci_pipelines.first throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present? Gitlab::DataBuilder::Pipeline.build(pipeline) diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb index 811828169ca..defd28d7d3b 100644 --- a/app/validators/duration_validator.rb +++ b/app/validators/duration_validator.rb @@ -14,6 +14,10 @@ class DurationValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) ChronicDuration.parse(value) rescue ChronicDuration::DurationParseError - record.errors.add(attribute, "is not a correct duration") + if options[:message] + record.errors.add(:base, options[:message]) + else + record.errors.add(attribute, "is not a correct duration") + end end end diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index b201e6bf10e..19c2a50ebd9 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -1,7 +1,7 @@ - sorted_by = sort_options_hash[@sort] .dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = sorted_by = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index bfbc16d37a0..a733f420d11 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -8,7 +8,7 @@ %span.cred (Admin) .float-right - - if @user != current_user && @user.can?(:log_in) + - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info" = link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do %i.fa.fa-pencil-square-o diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index f910e90d6ca..600120c4f05 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -9,28 +9,20 @@ .search-holder .search-field-holder = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false + - if @sort.present? + = hidden_field_tag :sort, @sort = icon("search", class: "search-icon") - .dropdown - - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end + = button_tag 'Search users' if Rails.env.test? + .dropdown.user-sort-dropdown + - toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header Sort by %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do - = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated + - users_sort_options_hash.each do |value, title| + = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do + = title = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search' .top-area.scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 85d1002243b..73b11d509d3 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,6 +1,6 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } - %button.close.js-close{ type: "button" } × +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } + %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × .gcp-signup-offer--content .gcp-signup-offer--icon.append-right-8 = sprite_icon("information", size: 16) diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 4dbda5c754b..31d4b3da4f1 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -4,9 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - -= render_if_exists "shared/gold_trial_callout" - - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 2f7add600e4..50f39f93283 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,8 +1,6 @@ - @hide_top_links = true - page_title "Groups" - header_title "Groups", dashboard_groups_path - -= render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index afd46412fab..fdd5c19d562 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Issues') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 3e5f13b92e3..77cfa1271df 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,8 +2,6 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Merge Requests') diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 446b4715b2d..deed774a4a5 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") -= render_if_exists "shared/gold_trial_callout" - - page_title "Projects" - header_title "Projects", dashboard_projects_path diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index ad08409c8fe..8933d9e31ff 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -4,8 +4,6 @@ - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - %div{ class: container_class } = render "projects/last_push" = render 'dashboard/projects_head' diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 47729321961..d2593179f17 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,8 +2,6 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Todos') diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml new file mode 100644 index 00000000000..5398430fdfd --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.haml @@ -0,0 +1,12 @@ += email_default_heading("Hello, #{@resource.name}!") + +- if @resource.try(:unconfirmed_email?) + %p + We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}. +- else + %p + We're contacting you to notify you that your email has been changed to #{@resource.email}. + +%p + If you did not initiate this change, please contact your administrator + immediately. diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb new file mode 100644 index 00000000000..18137389e7b --- /dev/null +++ b/app/views/devise/mailer/email_changed.text.erb @@ -0,0 +1,10 @@ +Hello, <%= @resource.name %>! + +<% if @resource.try(:unconfirmed_email?) %> +We're contacting you to notify you that your email is being changed to <%= @resource.reload.unconfirmed_email %>. +<% else %> +We're contacting you to notify you that your email has been changed to <%= @resource.email %>. +<% end %> + +If you did not initiate this change, please contact your administrator +immediately. diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml index 8ae29b9d337..46931b5932d 100644 --- a/app/views/errors/access_denied.html.haml +++ b/app/views/errors/access_denied.html.haml @@ -9,7 +9,7 @@ %p = message %p - = s_('403|Please contact your GitLab administrator to get the permission.') + = s_('403|Please contact your GitLab administrator to get permission.') .action-container.js-go-back{ style: 'display: none' } %a{ href: 'javascript:history.back()', class: 'btn btn-success' } = s_('Go Back') diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 869be4e8581..a3eafc61d0a 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/groups_head' - else diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index b694103ccaf..f518205f14c 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,8 +1,8 @@ - if current_user .dropdown - %button.dropdown-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } - = icon('globe') - %span.light= _("Visibility:") + %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } + = icon('globe', class: 'mt-1') + %span.light.ml-3= _("Visibility:") - if params[:visibility_level].present? = visibility_level_label(params[:visibility_level].to_i) - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index a0760c2073b..6219da2c715 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -1,4 +1,4 @@ -.group-home-panel.text-center +.group-home-panel.text-center.border-bottom %div{ class: container_class } .avatar-container.s70.group-avatar = group_icon(@group, class: "avatar s70 avatar-tile") diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 869c54d89ea..39d0f620283 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -37,6 +37,7 @@ .settings-content = render 'shared/badges/badge_settings' += render_if_exists 'groups/custom_project_templates_setting' = render_if_exists 'groups/templates_setting', expanded: expanded %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml index 836981fc6fd..586b0f6ebfa 100644 --- a/app/views/groups/labels/edit.html.haml +++ b/app/views/groups/labels/edit.html.haml @@ -1,4 +1,6 @@ -- page_title 'Edit', @label.name, 'Labels' +- add_to_breadcrumbs _("Labels"), group_labels_path(@group) +- breadcrumb_title _("Edit") +- page_title "Edit", @label.name, _("Labels") %h3.page-title Edit Label diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml index 538c353cf2d..bb0b8d2b94d 100644 --- a/app/views/groups/labels/new.html.haml +++ b/app/views/groups/labels/new.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title "Labels" -- page_title 'New Label' +- add_to_breadcrumbs _("Labels"), group_labels_path(@group) +- breadcrumb_title _("New") +- page_title _("New Label") %h3.page-title New Label diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml index 5f6d7d209d0..c703d5f7f93 100644 --- a/app/views/groups/milestones/edit.html.haml +++ b/app/views/groups/milestones/edit.html.haml @@ -1,7 +1,10 @@ -- page_title "Milestones" +- breadcrumb_title _("Edit") +- page_title _("Milestones") + - render "header_title" %h3.page-title Edit Milestone +%hr = render "form" diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index d758e314d41..248cb3b0ba5 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -1,7 +1,12 @@ -- breadcrumb_title "Milestones" -- page_title "Milestones" +- @no_container = true +- add_to_breadcrumbs _("Milestones"), group_milestones_path(@group) +- breadcrumb_title _("New") +- page_title _("Milestones"), @milestone.name, _("Milestones") -%h3.page-title - New Milestone +%div{ class: container_class } + %h3.page-title + New Milestone -= render "form" + %hr + + = render "form" diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml new file mode 100644 index 00000000000..b24d6e27536 --- /dev/null +++ b/app/views/ide/_show.html.haml @@ -0,0 +1,10 @@ +- @body_class = 'ide-layout' +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = stylesheet_link_tag 'page_bundles/ide' + +#ide.ide-loading{ data: ide_data() } + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index d8bd37fe986..0323f9d093d 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -1,17 +1 @@ -- @body_class = 'ide-layout' -- page_title 'IDE' - -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/ide' - -#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), - "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), - "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), - "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), - "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), - "ci-help-page-path" => help_page_path('ci/quick_start/README'), - "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), - "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } } - .text-center - = icon('spinner spin 2x') - %h2.clgray= _('Loading the GitLab IDE...') += render 'ide/show' diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index 3b1b5e55302..2336e1e83f9 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -37,11 +37,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _('done') - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _('started') - else diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 56d4f2ba881..ef69197e453 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -38,9 +38,10 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' = icon('check', text: 'Done') - - elsif project.import_status == 'started' + - when 'started' = icon('spin', text: 'started') - else = project.human_import_status_name diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index 830d141ebea..eca67582d6f 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -34,11 +34,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _("done") - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _("started") - else diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index b7bfbae5edf..a5fa12fe7df 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -30,11 +30,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _('done') - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _('started') - else diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index 347e2820f94..f322b7a956a 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -39,11 +39,12 @@ %td = link_to project.full_path, [project.namespace.becomes(Namespace), project] %td.job-status - - if project.import_status == 'finished' + - case project.import_status + - when 'finished' %span %i.fa.fa-check = _("done") - - elsif project.import_status == 'started' + - when 'started' %i.fa.fa-spinner.fa-spin = _("started") - else diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index c2bb1216c5f..30ab5781014 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -1,5 +1,5 @@ -- page_title "Invitation" -%h3.page-title Invitation +- page_title _("Invitation") +%h3.page-title= _("Invitation") %p You have been invited @@ -24,14 +24,17 @@ - if is_member %p - However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}. - Sign in using a different account to accept the invitation. + - member_source = @member.source.is_a?(Group) ? _("group") : _("project") + = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source } - if @member.invite_email != current_user.email %p - Note that this invitation was sent to #{mail_to @member.invite_email}, but you are signed in as #{link_to current_user.to_reference, user_url(current_user)} with email #{mail_to current_user.email}. + - mail_to_invite_email = mail_to(@member.invite_email) + - mail_to_current_user = mail_to(current_user.email) + - link_to_current_user = link_to(current_user.to_reference, user_url(current_user)) + = _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user } - unless is_member .actions - = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" - = link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" + = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success" + = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index ab15889a465..b89541a3c9f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -222,6 +222,12 @@ %span = _('Environments') + - if project_nav_tab? :serverless + = nav_link(controller: :functions) do + = link_to project_serverless_functions_path(@project), title: _('Serverless') do + %span + = _('Serverless') + - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) = nav_link(controller: [:clusters, :user, :gcp]) do diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index 94bd6f96dbc..1fbae2f64ed 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -1,13 +1,18 @@ -- discussion = @note.discussion if @note.part_of_discussion? +- note = local_assigns.fetch(:note, @note) +- diff_limit = local_assigns.fetch(:diff_limit, nil) +- target_url = local_assigns.fetch(:target_url, @target_url) +- note_style = local_assigns.fetch(:note_style, "") + +- discussion = note.discussion if note.part_of_discussion? - diff_discussion = discussion&.diff_discussion? - on_image = discussion.on_image? if diff_discussion - if discussion - phrase_end_char = on_image ? "." : ":" - %p.details + %p{ style: "color: #777777;" } = succeed phrase_end_char do - = link_to @note.author_name, user_url(@note.author) + = link_to note.author_name, user_url(note.author) - if diff_discussion - if discussion.new_discussion? @@ -15,16 +20,16 @@ - else commented on a discussion - on #{link_to discussion.file_path, @target_url} + on #{link_to discussion.file_path, target_url} - else - if discussion.new_discussion? started a new discussion - else - commented on a #{link_to 'discussion', @target_url} + commented on a #{link_to 'discussion', target_url} - elsif Gitlab::CurrentSettings.email_author_in_body %p.details - #{link_to @note.author_name, user_url(@note.author)} commented: + #{link_to note.author_name, user_url(note.author)} commented: - if diff_discussion && !on_image = content_for :head do @@ -32,11 +37,11 @@ %table = render partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, + collection: discussion.truncated_diff_lines(diff_limit: diff_limit), as: :line, locals: { diff_file: discussion.diff_file, plain: true, email: true } -%div - = markdown(@note.note, pipeline: :email, author: @note.author) +%div{ style: note_style } + = markdown(note.note, pipeline: :email, author: note.author) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index c319cb55e87..4bf252b6ce1 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -1,6 +1,9 @@ -<% discussion = @note.discussion if @note.part_of_discussion? -%> +<% note = local_assigns.fetch(:note, @note) -%> +<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%> + +<% discussion = note.discussion if note.part_of_discussion? -%> <% if discussion && !discussion.individual_note? -%> -<%= @note.author_name -%> +<%= note.author_name -%> <% if discussion.new_discussion? -%> <%= " started a new discussion" -%> <% else -%> @@ -13,14 +16,14 @@ <% elsif Gitlab::CurrentSettings.email_author_in_body -%> -<%= "#{@note.author_name} commented:" -%> +<%= "#{note.author_name} commented:" -%> <% end -%> <% if discussion&.diff_discussion? -%> -<% discussion.truncated_diff_lines(highlight: false).each do |line| -%> +<% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%> <%= "> #{line.text}\n" -%> <% end -%> <% end -%> -<%= @note.note -%> +<%= note.note -%> diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_project_snippet_email.html.haml index 5e69f01a486..5e69f01a486 100644 --- a/app/views/notify/note_snippet_email.html.haml +++ b/app/views/notify/note_project_snippet_email.html.haml diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_project_snippet_email.text.erb index 413d9e6e9ac..413d9e6e9ac 100644 --- a/app/views/notify/note_snippet_email.text.erb +++ b/app/views/notify/note_project_snippet_email.text.erb diff --git a/app/views/notify/repository_cleanup_failure_email.text.erb b/app/views/notify/repository_cleanup_failure_email.text.erb new file mode 100644 index 00000000000..f5a426a51d1 --- /dev/null +++ b/app/views/notify/repository_cleanup_failure_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup failed on <%= @project.web_url %> + +<%= @error %> diff --git a/app/views/notify/repository_cleanup_success_email.text.erb b/app/views/notify/repository_cleanup_success_email.text.erb new file mode 100644 index 00000000000..e6e95da2fcc --- /dev/null +++ b/app/views/notify/repository_cleanup_success_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup succeeded on <%= @project.web_url %> + +Repository size is now <%= "%.1f" % (@project.repository.size || 0) %> MiB diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 2220b4eee96..e167e094240 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -21,7 +21,7 @@ = link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success' %hr -- if button_based_providers.any? +- if display_providers_on_profile? .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 @@ -46,6 +46,7 @@ - else = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do Connect + = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities] %hr - if current_user.can_change_username? .row.prepend-top-default @@ -66,7 +67,7 @@ %h4.prepend-top-0.danger-title = s_('Profiles|Delete account') .col-lg-8 - - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) + - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) %p = s_('Profiles|Deleting an account has the following effects:') = render 'users/deletion_guidance', user: current_user @@ -79,10 +80,10 @@ confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), username: current_user.username } } - else - - if @user.solo_owned_groups.present? + - if current_user.solo_owned_groups.present? %p = s_('Profiles|Your account is currently an owner in these groups:') - %strong= @user.solo_owned_groups.map(&:name).join(', ') + %strong= current_user.solo_owned_groups.map(&:name).join(', ') %p = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - else diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 79530e78154..22a721ee9ad 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,7 +1,9 @@ +- is_project_overview = local_assigns.fetch(:is_project_overview, false) - commit = local_assigns.fetch(:commit) { @repository.commit } - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } - content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) } +- show_auto_devops_callout = show_auto_devops_callout?(@project) #tree-holder.tree-holder.clearfix .nav-block @@ -10,4 +12,8 @@ - if commit = render 'shared/commit_well', commit: commit, ref: ref, project: project + - if is_project_overview + .project-buttons.append-bottom-default + = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) + = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index dcef4dd5b69..e191b009db2 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,83 +1,75 @@ - empty_repo = @project.empty_repo? -- license = @project.license_anchor_data +- show_auto_devops_callout = show_auto_devops_callout?(@project) .project-home-panel{ class: ("empty-project" if empty_repo) } - .limit-container-width{ class: container_class } - .project-header.d-flex.flex-row.flex-wrap.align-items-center.append-bottom-8 - .project-title-row.d-flex.align-items-center - .avatar-container.project-avatar.float-none - = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s24', width: 24, height: 24) - %h1.project-title.d-flex.align-items-baseline.qa-project-name - = @project.name - .project-metadata.d-flex.flex-row.flex-wrap.align-items-baseline - .project-visibility.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } - = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) - = visibility_level_label(@project.visibility_level) - - if license.present? - .project-license.d-inline-flex.align-items-baseline - = link_to_if license.link, sprite_icon('scale', size: 16, css_class: 'icon') + license.label, license.link, class: license.enabled ? 'btn btn-link btn-secondary-hover-link' : 'btn btn-link' - - if @project.tag_list.present? - .project-tag-list.d-inline-flex.align-items-baseline.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } - = sprite_icon('tag', size: 16, css_class: 'icon') - = @project.tags_to_show - - if @project.has_extra_tags? - = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } + .project-header.row.append-bottom-8 + .project-title-row.col-md-12.col-lg-7.d-flex + .avatar-container.project-avatar.float-none + = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) + .d-flex.flex-column.flex-wrap.align-items-baseline + .d-inline-flex.align-items-baseline + %h1.project-title.qa-project-name + = @project.name + %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } + = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) + .project-metadata.d-flex.align-items-center + - if can?(current_user, :read_project, @project) + %span.text-secondary + = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } + - if current_user + %span.access-request-links.prepend-left-8 + = render 'shared/members/access_request_links', source: @project + - if @project.tag_list.present? + %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } + = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') + = @project.tags_to_show + - if @project.has_extra_tags? + = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } - .project-home-desc - - if @project.description.present? - .project-description - .project-description-markdown.read-more-container - = markdown_field(@project, :description) - %button.btn.btn-blank.btn-link.text-secondary.js-read-more-trigger.text-secondary.d-lg-none{ type: "button" } - = _("Read more") - - - if can?(current_user, :read_project, @project) - .text-secondary.prepend-top-8 - = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - - - if @project.forked? - %p - - if @project.fork_source - #{ s_('ForkedFromProjectPath|Forked from') } - = link_to project_path(@project.fork_source) do - = fork_source_name(@project) - - else - - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') - = deleted_message % { project_name: fork_source_name(@project) } - - - if @project.badges.present? - .project-badges.prepend-top-default.append-bottom-default - - @project.badges.each do |badge| - %a.append-right-8{ href: badge.rendered_link_url(@project), - target: '_blank', - rel: 'noopener noreferrer' }> - %img.project-badge{ src: badge.rendered_image_url(@project), - 'aria-hidden': true, - alt: 'Project badge' }> + .project-repo-buttons.col-md-12.col-lg-5.d-inline-flex.flex-wrap.justify-content-lg-end + - if current_user + .d-inline-flex + = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs' - .project-repo-buttons.d-inline-flex.flex-wrap .count-buttons.d-inline-flex = render 'projects/buttons/star' = render 'projects/buttons/fork' - if can?(current_user, :download_code, @project) - .project-clone-holder.d-inline-flex.d-sm-none + .project-clone-holder.d-inline-flex.d-md-none.btn-block = render "shared/mobile_clone_panel" - .project-clone-holder.d-none.d-sm-inline-flex - = render "shared/clone_panel" + .project-clone-holder.d-none.d-md-inline-flex + = render "projects/buttons/clone" - - if show_xcode_link?(@project) - .project-action-button.project-xcode.inline - = render "projects/buttons/xcode_link" + - if can?(current_user, :download_code, @project) + %nav.project-stats + .nav-links.quick-links.mt-3 + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - - if current_user - - if can?(current_user, :download_code, @project) - .d-none.d-sm-inline-flex - = render 'projects/buttons/download', project: @project, ref: @ref - .d-none.d-sm-inline-flex - = render 'projects/buttons/dropdown' + .project-home-desc.mt-1 + - if @project.description.present? + .project-description + .project-description-markdown.read-more-container + = markdown_field(@project, :description) + %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } + = _("Read more") + + - if @project.forked? + %p + - if @project.fork_source + #{ s_('ForkedFromProjectPath|Forked from') } + = link_to project_path(@project.fork_source) do + = fork_source_name(@project) + - else + - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') + = deleted_message % { project_name: fork_source_name(@project) } - .d-none.d-sm-inline-flex - = render 'shared/notifications/button', notification_setting: @notification_setting - .d-none.d-sm-inline-flex - = render 'shared/members/access_request_buttons', source: @project + - if @project.badges.present? + .project-badges.mb-2 + - @project.badges.each do |badge| + %a.append-right-8{ href: badge.rendered_link_url(@project), + target: '_blank', + rel: 'noopener noreferrer' }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: 'Project badge' }> diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 4cf49f3cf62..8e3d759b683 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -4,5 +4,5 @@ %ul.nav - anchors.each do |anchor| %li.nav-item - = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'nav-link stat-link' : "nav-link btn btn-#{anchor.class_modifier || 'missing'}" do - .stat-text= anchor.label + = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do + .stat-text.d-flex.align-items-center= anchor.label diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml new file mode 100644 index 00000000000..d82a3dd70f9 --- /dev/null +++ b/app/views/projects/buttons/_clone.html.haml @@ -0,0 +1,31 @@ +- project = project || @project + +.git-clone-holder.js-git-clone-holder.input-group + - if allowed_protocols_present? + .input-group-text.clone-dropdown-btn.btn + %span.js-clone-dropdown-label + = enabled_project_button(project, enabled_protocol) + - else + %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %span.append-right-4.js-clone-dropdown-label + = _('Clone') + = sprite_icon("arrow-down", css_class: "icon") + %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown + %li.pb-2 + %label.label-bold + = _('Clone with SSH') + .input-group + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } + .input-group-append + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = render_if_exists 'projects/buttons/geo' + %li + %label.label-bold + = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } + .input-group + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } + .input-group-append + = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = render_if_exists 'projects/buttons/geo' + += render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index f7551434d47..4eb53faa6ff 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -5,8 +5,8 @@ .project-action-button.dropdown.inline> %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static' } = sprite_icon('download') - = icon("caret-down") %span.sr-only= _('Select Archive Format') + = sprite_icon("arrow-down") %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } %li.dropdown-header #{ _('Source code') } diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 8da27ca7cb3..bc0a89bea62 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -1,9 +1,6 @@ - unless @project.empty_repo? - if current_user && can?(current_user, :fork_project, @project) .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.fork-count.count-badge-count.d-flex.align-items-center - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do - = @project.forks_count - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do = sprite_icon('fork', { css_class: 'icon' }) @@ -15,3 +12,6 @@ title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do = sprite_icon('fork', { css_class: 'icon' }) %span= s_('ProjectOverview|Fork') + %span.fork-count.count-badge-count.d-flex.align-items-center + = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do + = @project.forks_count diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml new file mode 100644 index 00000000000..745983ace7e --- /dev/null +++ b/app/views/projects/buttons/_notifications.html.haml @@ -0,0 +1,27 @@ +- btn_class = local_assigns.fetch(:btn_class, "btn-xs") + +- if notification_setting + .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline + = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| + = hidden_setting_source_input(notification_setting) + = hidden_field_tag "hide_label", true + = f.hidden_field :level, class: "notification_setting_level" + .js-notification-toggle-btns + %div{ class: ("btn-group" if notification_setting.custom?) } + - if notification_setting.custom? + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon("arrow-down", css_class: "icon") + .sr-only Toggle dropdown + - else + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + = sprite_icon("arrow-down", css_class: "icon") + + = render "shared/notifications/notification_dropdown", notification_setting: notification_setting + + = content_for :scripts_body do + = render "shared/notifications/custom_notifications", notification_setting: notification_setting diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 0d04ecb3a58..090d1549aa7 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,19 +1,19 @@ - if current_user .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.star-count.count-badge-count.d-flex.align-items-center - = @project.star_count - %button.count-badge-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } + %button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } - if current_user.starred?(@project) = sprite_icon('star', { css_class: 'icon' }) %span.starred= s_('ProjectOverview|Unstar') - else = sprite_icon('star-o', { css_class: 'icon' }) %span= s_('ProjectOverview|Star') + %span.star-count.count-badge-count.d-flex.align-items-center + = @project.star_count - else .count-badge.d-inline-flex.align-item-stretch.append-right-8 - %span.star-count.count-badge-count.d-flex.align-items-center - = @project.star_count - = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do + = link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do = sprite_icon('star-o', { css_class: 'icon' }) %span= s_('ProjectOverview|Star') + %span.star-count.count-badge-count.d-flex.align-items-center + = @project.star_count diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml new file mode 100644 index 00000000000..778d27fc61d --- /dev/null +++ b/app/views/projects/cleanup/_show.html.haml @@ -0,0 +1,31 @@ +- return unless Feature.enabled?(:project_cleanup, @project) + +- expanded = Rails.env.test? + +%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } + .settings-header + %h4= _('Repository cleanup') + %button.btn.js-settings-toggle + = expanded ? _('Collapse') : _('Expand') + %p + = _("Clean up after running %{bfg} on the repository" % { bfg: link_to_bfg }).html_safe + = link_to icon('question-circle'), + help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'), + target: '_blank', rel: 'noopener noreferrer' + + .settings-content + - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project) + = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| + %fieldset.prepend-top-0.append-bottom-10 + .append-bottom-10 + %h5.prepend-top-0 + = _("Upload object map") + %button.btn.btn-default.js-choose-file{ type: "button" } + = _("Choose a file") + %span.prepend-left-default.js-filename + = _("No file selected") + = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true + .form-text.text-muted + = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size } + = f.submit _('Start cleanup'), class: 'btn btn-success' + diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index c6789e32dbe..1a74b120c26 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -8,62 +8,50 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - link = commit_path(project, commit, merge_request: merge_request) -- cache_key = [project.full_path, - ref, - commit.id, - Gitlab::CurrentSettings.current_application_settings, - @path.presence, - current_controller?(:commits), - merge_request&.iid, - view_details, - commit.status(ref), - I18n.locale].compact - -= cache(cache_key, expires_in: 1.day) do - %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } - - .avatar-cell.d-none.d-sm-block - = author_avatar(commit, size: 36, has_tooltip: false) - - .commit-detail.flex-list - .commit-content.qa-commit-content - - if view_details && merge_request - = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" - - else - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") - %span.commit-row-message.d-block.d-sm-none - · - = commit.short_id - - if commit.status(ref) - .d-block.d-sm-none - = render_commit_status(commit, ref: ref) - - if commit.description? - %button.text-expander.js-toggle-button - = sprite_icon('ellipsis_h', size: 12) +%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } + + .avatar-cell.d-none.d-sm-block + = author_avatar(commit, size: 36, has_tooltip: false) + + .commit-detail.flex-list + .commit-content.qa-commit-content + - if view_details && merge_request + = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title" + - else + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") + %span.commit-row-message.d-block.d-sm-none + · + = commit.short_id + - if commit.status(ref) + .d-block.d-sm-none + = render_commit_status(commit, ref: ref) + - if commit.description? + %button.text-expander.js-toggle-button + = sprite_icon('ellipsis_h', size: 12) - .committer - - commit_author_link = commit_author_link(commit, avatar: false, size: 24) - - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } - #{ commit_text.html_safe } + .committer + - commit_author_link = commit_author_link(commit, avatar: false, size: 24) + - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') + - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } + #{ commit_text.html_safe } - - if commit.description? - %pre.commit-row-description.js-toggle-content.append-bottom-8 - = preserve(markdown_field(commit, :description)) + - if commit.description? + %pre.commit-row-description.js-toggle-content.append-bottom-8 + = preserve(markdown_field(commit, :description)) - .commit-actions.flex-row.d-none.d-sm-flex - - if request.xhr? - = render partial: 'projects/commit/signature', object: commit.signature - - else - = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } + .commit-actions.flex-row.d-none.d-sm-flex + - if request.xhr? + = render partial: 'projects/commit/signature', object: commit.signature + - else + = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - - if commit.status(ref) - = render_commit_status(commit, ref: ref) + - if commit.status(ref) + = render_commit_status(commit, ref: ref) - .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } + .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } - .commit-sha-group - .label.label-monospace - = commit.short_id - = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") - = link_to_browse_code(project, commit) + .commit-sha-group + .label.label-monospace + = commit.short_id + = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body") + = link_to_browse_code(project, commit) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f376df29878..1b52821af15 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -53,7 +53,7 @@ = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } .prepend-top-5.append-bottom-10 %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...") - %span.file_name.prepend-left-default.js-avatar-filename= _("No file chosen") + %span.file_name.prepend-left-default.js-filename= _("No file chosen") = f.file_field :avatar, class: "js-project-avatar-input hidden" .form-text.text-muted= _("The maximum file size allowed is 200KB.") - if @project.avatar? diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 936900a0087..aa690b12eb7 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -4,11 +4,10 @@ = render partial: 'flash_messages', locals: { project: @project } -= render "home_panel" +%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } + = render "home_panel" -.project-empty-note-panel - %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .prepend-top-20 + .project-empty-note-panel %h4.append-bottom-20 = _('The repository for this project is empty') @@ -32,66 +31,65 @@ = _('Otherwise it is recommended you start with one of the options below.') .prepend-top-20 -%nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.quick-links - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons + %nav.project-buttons + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + .nav-links.scrolling-tabs.quick-links + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons -- if can?(current_user, :push_code, @project) - %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .prepend-top-20 - .empty_wrapper - %h3#repo-command-line-instructions.page-title-empty - Command line instructions - .git-empty.js-git-empty - %fieldset - %h5 Git global setup - %pre.bg-light - :preserve - git config --global user.name "#{h git_user_name}" - git config --global user.email "#{h git_user_email}" + - if can?(current_user, :push_code, @project) + %div + .prepend-top-20 + .empty_wrapper + %h3#repo-command-line-instructions.page-title-empty + = _('Command line instructions') + .git-empty.js-git-empty + %fieldset + %h5= _('Git global setup') + %pre.bg-light + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" - %fieldset - %h5 Create a new repository - %pre.bg-light - :preserve - git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - cd #{h @project.path} - touch README.md - git add README.md - git commit -m "add README" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + %fieldset + %h5= _('Create a new repository') + %pre.bg-light + :preserve + git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + cd #{h @project.path} + touch README.md + git add README.md + git commit -m "add README" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - %fieldset - %h5 Existing folder - %pre.bg-light - :preserve - cd existing_folder - git init - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - git add . - git commit -m "Initial commit" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin master + %fieldset + %h5= _('Existing folder') + %pre.bg-light + :preserve + cd existing_folder + git init + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin master - %fieldset - %h5 Existing Git repository - %pre.bg-light - :preserve - cd existing_repo - git remote rename origin old-origin - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - - if @project.can_current_user_push_to_default_branch? - %span>< - git push -u origin --all - git push -u origin --tags + %fieldset + %h5= _('Existing Git repository') + %pre.bg-light + :preserve + cd existing_repo + git remote rename origin old-origin + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + - if @project.can_current_user_push_to_default_branch? + %span>< + git push -u origin --all + git push -u origin --tags - - if can? current_user, :remove_project, @project - .prepend-top-20 - = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" + - if can? current_user, :remove_project, @project + .prepend-top-20 + = link_to _('Remove project'), [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 996c7b1b960..82f035f24da 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,6 +1,6 @@ - page_title "Find File", @ref -.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, @options.merge(format: :json)))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } +.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } .nav-block .tree-ref-holder = render 'shared/ref_switcher', destination: 'find_file', path: @path diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index b44ea89510b..c63c34c4ebb 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -9,7 +9,7 @@ spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } .dropdown - %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.light sort: - if @sort.present? = sort_options_hash[@sort] diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index 1c50cfbde85..bd0ab2c19f2 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -10,7 +10,7 @@ .card-body %pre :preserve - #{h(@project.import_error)} + #{h(@project.import_state.last_error)} = form_for @project, url: project_import_path(@project), method: :post do |f| = render "shared/import_form", f: f diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 3b0c828ccd1..422a3a22f87 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -1,4 +1,6 @@ - page_title import_in_progress_title +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout .save-project-loader .center diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index b8ee4305142..b9d45e83032 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,4 +1,6 @@ - @no_container = true +- add_to_breadcrumbs "Labels", project_labels_path(@project) +- breadcrumb_title "Edit" - page_title "Edit", @label.name, "Labels" %div{ class: container_class } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 2c6484c2c99..56b06374d6d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -5,7 +5,7 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -- if @labels.present? && can_admin_label +- if labels_or_filters && can_admin_label - content_for(:header_content) do .nav-controls = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index 02f59f30a39..c6739231e36 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,5 +1,6 @@ - @no_container = true -- breadcrumb_title "Labels" +- add_to_breadcrumbs "Labels", project_labels_path(@project) +- breadcrumb_title "New" - page_title "New Label" %div{ class: container_class } diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index af3f25c6a30..4006a468792 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,6 +1,9 @@ - @no_container = true +- breadcrumb_title "Edit" +- add_to_breadcrumbs "Milestones", project_milestones_path(@project) - page_title "Edit", @milestone.title, "Milestones" + %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index c301f517013..01cc951e8c2 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,5 +1,6 @@ - @no_container = true -- breadcrumb_title "Milestones" +- add_to_breadcrumbs "Milestones", project_milestones_path(@project) +- breadcrumb_title "New" - page_title "New Milestone" %div{ class: container_class } diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 8dc042d87d1..293a2e3ebfe 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -8,14 +8,14 @@ = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control js-mirror-auth-type" } + {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } .form-group .collapse.js-well-changing-auth .changing-auth-method= icon('spinner spin lg') .well-password-auth.collapse.js-well-password-auth = f.label :password, _("Password"), class: "label-bold" - = f.password_field :password, value: mirror.password, class: 'form-control' + = f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password' - unless is_push .well-ssh-auth.collapse.js-well-ssh-auth %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) } diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 2f9bd5b04b6..21b105e6f80 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,7 +1,7 @@ - expanded = Rails.env.test? - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') -%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) } +%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Mirroring repositories') %button.btn.js-settings-toggle @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" = render 'projects/mirrors/instructions' @@ -32,7 +32,7 @@ = link_to icon('question-circle'), help_page_path('user/project/protected_branches') .panel-footer - = f.submit _('Mirror repository'), class: 'btn btn-success', name: :update_remote_mirror + = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror .panel.panel-default .table-responsive @@ -50,10 +50,10 @@ = render_if_exists 'projects/mirrors/table_pull_row' - @project.remote_mirrors.each_with_index do |mirror, index| - if mirror.enabled - %tr - %td= mirror.safe_url + %tr.qa-mirrored-repository-row + %td.qa-mirror-repository-url= mirror.safe_url %td= _('Push') - %td= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') %td - if mirror.last_error.present? .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index a2cce83bfab..b49f1d9315e 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,5 +1,5 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 10e3b01096a..a760d02c4c3 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -50,7 +50,7 @@ .project-template .form-group %div - = render 'project_templates', f: f + = render 'project_templates', f: f, project: @project .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } - if import_sources_enabled? diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 2575efc0981..0f0114d513c 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -24,6 +24,38 @@ - if @pipeline.queued_duration = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .well-segment + .icon-container + = sprite_icon('flag') + - if @pipeline.latest? + %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for this branch") } + latest + - if @pipeline.has_yaml_errors? + %span.js-pipeline-url-yaml.badge.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } + yaml invalid + - if @pipeline.failure_reason? + %span.js-pipeline-url-failure.badge.badge-danger.has-tooltip{ title: @pipeline.failure_reason } + error + - if @pipeline.auto_devops_source? + - popover_title_text = _('This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>').html_safe + - popover_content_url = help_page_path('topics/autodevops/index.md') + - popover_content_text = _('Learn more about Auto DevOps') + %a.js-pipeline-url-autodevops.badge.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body", + toggle: "popover", + placement: "top", + html: "true", + trigger: "focus", + title: "<div class='autodevops-title'>#{popover_title_text}</div>", + content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", + } } + Auto DevOps + - if @pipeline.merge_request? + %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" } + merge request + - if @pipeline.stuck? + %span.js-pipeline-url-stuck.badge.badge-warning + stuck + .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml index 233c3adba0e..5b4d8927045 100644 --- a/app/views/projects/project_templates/_built_in_templates.html.haml +++ b/app/views/projects/project_templates/_built_in_templates.html.haml @@ -9,9 +9,9 @@ .text-muted = template.description .controls.d-flex.align-items-center - %label.btn.btn-success.template-button.choose-template.append-right-10.append-bottom-0{ for: template.name } + %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } + = _("Preview") + %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } %span = _("Use template") - %a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } - = _("Preview") diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml new file mode 100644 index 00000000000..f650fa0f38f --- /dev/null +++ b/app/views/projects/serverless/functions/index.html.haml @@ -0,0 +1,15 @@ +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout +- breadcrumb_title 'Serverless' +- page_title 'Serverless' +- status_path = project_serverless_functions_path(@project, format: :json) +- clusters_path = project_clusters_path(@project) + +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } + +%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } + .js-serverless-functions-notice + .flash-container + + .top-area.adjust + .serverless-functions-table#js-serverless-functions diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 621b7922072..bb328f5344c 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -29,7 +29,7 @@ = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold' = f.text_field :build_timeout_human_readable, class: 'form-control' %p.form-text.text-muted - = _("Per job. If a job passes this threshold, it will be marked as failed") + = _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.') = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index c14e95a382c..cb3a035c49e 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -13,3 +13,4 @@ = render "projects/protected_tags/index" = render @deploy_keys = render "projects/deploy_tokens/index" += render "projects/cleanup/show" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index f29ce4f5c06..c87a084740b 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,7 +1,6 @@ - @no_container = true - breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout -- show_auto_devops_callout = show_auto_devops_callout?(@project) = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") @@ -15,20 +14,11 @@ %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } = render "projects/last_push" -= render "home_panel" - -- if can?(current_user, :download_code, @project) - %nav.project-stats{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.quick-links - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) + = render "home_panel" + - if can?(current_user, :download_code, @project) && @project.repository_languages.present? = repository_languages_bar(@project.repository_languages) -%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } - if @project.archived? .text-warning.center.prepend-top-20 %p @@ -41,4 +31,4 @@ = render 'shared/auto_devops_callout' %div{ class: project_child_container_class(view_path) } - = render view_path + = render view_path, is_project_overview: true diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 37535370940..026bc44a05f 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -14,7 +14,7 @@ = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } .dropdown - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light = tags_sort_options_hash[@sort] = icon('chevron-down') diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 5e0523f0b96..889a13339fd 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,5 +1,5 @@ .tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder + .table-holder.bordered-box %table.table#tree-slider{ class: "table_#{@hex_path} tree-table qa-file-tree" } %thead %tr diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 601e3f25852..a89df6adfb3 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -85,4 +85,8 @@ = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do = _('Web IDE') + - if show_xcode_link?(@project) + .project-action-button.project-xcode.inline + = render "projects/buttons/xcode_link" + = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml index 2423ac6abce..769d869bd53 100644 --- a/app/views/projects/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml @@ -1,3 +1,3 @@ %li{ class: active_when(params[:id] == wiki_page.slug) } = link_to project_wiki_path(@project, wiki_page) do - = wiki_page.title.capitalize + = wiki_page.human_title diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 80aa1500d53..26671a7b7d2 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -1,5 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title _("Edit"), @page.title.capitalize, _("Wiki") +- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, @page) +- breadcrumb_title @page.persisted? ? _("Edit") : _("New") +- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki") = wiki_page_errors(@error) @@ -10,9 +12,9 @@ .nav-text %h2.wiki-page-title - if @page.persisted? - = link_to @page.title.capitalize, project_wiki_path(@project, @page) + = link_to @page.human_title, project_wiki_path(@project, @page) - else - = @page.title.capitalize + = @page.human_title %span.light · - if @page.persisted? @@ -28,7 +30,7 @@ = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") - if can?(current_user, :admin_wiki, @project) - #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.title.capitalize } } + #delete-wiki-modal-wrapper{ data: { delete_wiki_url: project_wiki_path(@project, @page), page_title: @page.human_title } } = render 'form', uploads_path: wiki_attachment_upload_url diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml index 969a1677d9a..c5fbeeafa54 100644 --- a/app/views/projects/wikis/history.html.haml +++ b/app/views/projects/wikis/history.html.haml @@ -1,4 +1,4 @@ -- page_title _("History"), @page.title.capitalize, _("Wiki") +- page_title _("History"), @page.human_title, _("Wiki") .wiki-page-header.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } @@ -6,7 +6,7 @@ .nav-text %h2.wiki-page-title - = link_to @page.title.capitalize, project_wiki_path(@project, @page) + = link_to @page.human_title, project_wiki_path(@project, @page) %span.light · = _("History") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index fbf248c2058..cc38ec12fd8 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title @page.title.capitalize +- breadcrumb_title @page.human_title - wiki_breadcrumb_dropdown_links(@page.slug) -- page_title @page.title.capitalize, _("Wiki") +- page_title @page.human_title, _("Wiki") - add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project) .wiki-page-header.has-sidebar-toggle @@ -9,7 +9,7 @@ = icon('angle-double-left') .nav-text - %h2.wiki-page-title= @page.title.capitalize + %h2.wiki-page-title= @page.human_title %span.wiki-last-edit-by - if @page.last_version = (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index a8d4d4af93a..2a602095845 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,7 +1,7 @@ - project = find_project_for_result_blob(blob) - return unless project -- file_name, blob = parse_search_result(blob) -- blob_link = project_blob_path(project, tree_join(blob.ref, file_name)) +- blob = parse_search_result(blob) +- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: file_name, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link } diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 4346217c230..389e4cc75b9 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,5 @@ - project = find_project_for_result_blob(wiki_blob) -- file_name, wiki_blob = parse_search_result(wiki_blob) +- wiki_blob = parse_search_result(wiki_blob) - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) -= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: file_name, blob_link: wiki_blob_link } += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 6c4607b2f16..0d0a3c1aa64 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,6 +1,6 @@ - if show_auto_devops_implicitly_enabled_banner?(project) .auto-devops-implicitly-enabled-banner.alert.alert-warning - - more_information_link = link_to _('More information'), 'https://docs.gitlab.com/ee/topics/autodevops/', class: 'alert-link' + - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link' - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } = auto_devops_message.html_safe .alert-link-group diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index a6ba3b59365..bd68a3e4c84 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -1,5 +1,5 @@ .dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } %span.light - if @sort.present? = milestone_sort_options_hash[@sort] diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 998985cabe1..b43662947a8 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -1,13 +1,13 @@ - project = project || @project - ssh_copy_label = _("Copy SSH clone URL") -- http_copy_label = _("Copy HTTPS clone URL") +- http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase } -.btn-group.mobile-git-clone.js-mobile-git-clone - = clipboard_button(button_text: default_clone_label, target: '#project_clone', hide_button_icon: true, class: "input-group-text clone-dropdown-btn js-clone-dropdown-label btn btn-default") - %button.btn.btn-default.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } - = icon("caret-down", class: "dropdown-btn-icon") +.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block + = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") + %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } + = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } %li - = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }) + = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true) %li = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index f32cff18fa8..721a2af8069 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -2,5 +2,5 @@ %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') } = icon("refresh spin") - else - = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do + = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do = icon("refresh") diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml deleted file mode 100644 index be6d4f1c32b..00000000000 --- a/app/views/shared/_sort_dropdown.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- sorted_by = sort_options_hash[@sort] -- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' - -.dropdown.inline.prepend-left-10 - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } - = sorted_by - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort - %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sorted_by) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sorted_by) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sorted_by) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sorted_by) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sorted_by) diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 2237b93a10b..1ae6d1f5ee3 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -9,7 +9,7 @@ - default_sort_by = sort_value_recently_created .dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 - %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label = options_hash[default_sort_by] = icon('chevron-down') diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml new file mode 100644 index 00000000000..2ca4657851c --- /dev/null +++ b/app/views/shared/issuable/_filter.html.haml @@ -0,0 +1,32 @@ +.issues-filters + .issues-details-filters.row-content-block.second-block + = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + .issues-other-filters + .filter-item.inline + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) + + .filter-item.inline + - if params[:assignee_id].present? + = hidden_field_tag(:assignee_id, params[:assignee_id]) + = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + + .filter-item.inline.milestone-filter + = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true + + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } + + - unless @no_filters_set + .float-right + = render 'shared/issuable/sort_dropdown' + + - has_labels = @labels && @labels.any? + .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } + - if has_labels + = render 'shared/labels_row', labels: @labels diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 6939aba6896..46634693067 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -2,7 +2,6 @@ - board = local_assigns.fetch(:board, nil) - block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.parent) -- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) .issues-filters .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } @@ -95,7 +94,10 @@ %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link{ type: 'button' } - = _('No Label') + = _('None') + %li.filter-dropdown-item{ data: { value: 'any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item @@ -139,5 +141,5 @@ - if @project #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-toggle-focus-btn - - elsif show_sorting_dropdown - = render 'shared/sort_dropdown' + - elsif type != :boards_modal + = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml new file mode 100644 index 00000000000..c211b9fcaa2 --- /dev/null +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -0,0 +1,20 @@ +- sort_value = @sort +- sort_title = issuable_sort_option_title(sort_value) +- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' + +.dropdown.inline.prepend-left-10.issue-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sort_title) + = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index 8a7d037e15b..d664ef1cc2f 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -1,6 +1,6 @@ - sort_title = label_sort_options_hash[@sort] || sort_title_name_desc .dropdown.inline - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown' } } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } = sort_title = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml new file mode 100644 index 00000000000..f7227b9101e --- /dev/null +++ b/app/views/shared/members/_access_request_links.html.haml @@ -0,0 +1,17 @@ +- model_name = source.model_name.to_s.downcase + +- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord + - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') + = link_to link_text, polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: leave_confirmation_message(source) }, + class: 'access-request-link' +- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord + = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: remove_member_message(requester) }, + class: 'access-request-link' +- elsif source.request_access_enabled && can?(current_user, :request_access, source) + = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), + method: :post, + class: 'access-request-link' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index a7fd75d85d7..6b3841ebbc4 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -75,7 +75,7 @@ = dropdown_title(_("Change permissions")) .dropdown-content %ul - - member.access_level_roles.each do |role, role_id| + - member.valid_level_roles.each do |role, role_id| %li = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 3dd2842be4f..ed7fefba56d 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -35,8 +35,8 @@ .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end - if @project - - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? - - if @project.group + - if can_admin_project_milestones? and milestone.active? + - if can_admin_group_milestones? %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), disabled: true, type: 'button', diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index f6c7ca70ebd..30860988bbb 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,3 +1,5 @@ +- btn_class = local_assigns.fetch(:btn_class, nil) + - if notification_setting .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| @@ -6,14 +8,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) = icon("caret-down") diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index daf08d9bb2c..559b5aa9c1e 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -45,7 +45,7 @@ = _('Maximum job timeout') .col-sm-10 = f.text_field :maximum_timeout_human_readable, class: 'form-control' - .form-text.text-muted= _('This timeout will take precedence when lower than Project-defined timeout') + .form-text.text-muted= _('This timeout will take precedence when lower than project-defined timeout and accepts a human readable time input language like "1 hour". Values without specification represent seconds.') .form-group.row = label_tag :tag_list, class: 'col-form-label col-sm-2' do = _('Tags') diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 0ce13ee7a53..ef8664e6f47 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -3,31 +3,31 @@ .d-none.d-sm-block - if can?(current_user, :update_personal_snippet, @snippet) = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do - Edit + = _("Edit") - if can?(current_user, :admin_personal_snippet, @snippet) - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do - Delete - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-success", title: "New snippet" do - New snippet + = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do + = _("Delete") + = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do + = _("New snippet") - if @snippet.submittable_as_spam_by?(current_user) - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') .d-block.d-sm-none.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } - Options + = _("Options") = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul %li - = link_to new_snippet_path, title: "New snippet" do - New snippet + = link_to new_snippet_path, title: _("New snippet") do + = _("New snippet") - if can?(current_user, :admin_personal_snippet, @snippet) %li - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do - Delete + = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do + = _("Delete") - if can?(current_user, :update_personal_snippet, @snippet) %li = link_to edit_snippet_path(@snippet) do - Edit + = _("Edit") - if @snippet.submittable_as_spam_by?(current_user) %li - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post + = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index dfea8b40bd8..69d41f8fe5e 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -5,6 +5,6 @@ = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } - if @snippets.empty? %li - .nothing-here-block Nothing here. + .nothing-here-block= _("Nothing here.") = paginate @snippets, theme: 'gitlab' diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index dc4b0fd9ba0..c312226dd6c 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -4,7 +4,7 @@ .nav-links.snippet-scope-menu.mobile-separator.nav.nav-tabs %li{ class: active_when(params[:scope].nil?) } = link_to subject_snippets_path(subject) do - All + = _("All") %span.badge.badge-pill - if include_private = subject.snippets.count @@ -14,18 +14,18 @@ - if include_private %li{ class: active_when(params[:scope] == "are_private") } = link_to subject_snippets_path(subject, scope: 'are_private') do - Private + = _("Private") %span.badge.badge-pill = subject.snippets.are_private.count %li{ class: active_when(params[:scope] == "are_internal") } = link_to subject_snippets_path(subject, scope: 'are_internal') do - Internal + = _("Internal") %span.badge.badge-pill = subject.snippets.are_internal.count %li{ class: active_when(params[:scope] == "are_public") } = link_to subject_snippets_path(subject, scope: 'are_public') do - Public + = _("Public") %span.badge.badge-pill = subject.snippets.are_public.count diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index 18ebeb78f87..ebc6c0a2605 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -1,5 +1,6 @@ -- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") + %h3.page-title - Edit Snippet + = _("Edit Snippet") %hr = render 'shared/snippets/form', url: snippet_path(@snippet) diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml index 9b4a7dbe68d..4f418e2381f 100644 --- a/app/views/snippets/index.html.haml +++ b/app/views/snippets/index.html.haml @@ -1,13 +1,13 @@ -- page_title "By #{@user.name}", "Snippets" +- page_title _("By %{user_name}") % { user_name: @user.name }, _("Snippets") %ol.breadcrumb %li.breadcrumb-item = link_to snippets_path do - Snippets + = _("Snippets") %li.breadcrumb-item = @user.name .float-right.d-none.d-sm-block = link_to user_path(@user) do - #{@user.name} profile page + = _("%{user_name} profile page") % { user_name: @user.name } = render 'snippets' diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml index 6bc748d346e..114c777bdc2 100644 --- a/app/views/snippets/new.html.haml +++ b/app/views/snippets/new.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true - @hide_breadcrumbs = true -- page_title "New Snippet" +- page_title _("New Snippet") .page-title-holder %h1.page-title= _('New Snippet') diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 220ba2b49e6..01b95145937 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,7 +1,7 @@ - if current_user - if note.emoji_awardable? .note-actions-item - = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do + = link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') @@ -9,7 +9,7 @@ - if note_editable .note-actions-item - = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do + = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do %span.link-highlight = custom_icon('icon_pencil') diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 578327883e5..36b4e00e8d5 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -1,8 +1,8 @@ - @hide_top_links = true - @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout -- add_to_breadcrumbs "Snippets", dashboard_snippets_path +- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path - breadcrumb_title @snippet.to_reference -- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") = render 'shared/snippets/header' diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 938cb579e9f..01acbf8eadd 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -7,7 +7,7 @@ %li %span.light %i.fa.fa-clock-o - = event.created_at.strftime('%-I:%M%P') + = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - if event.push? #{event.action_name} #{event.ref_type} diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index c0b410472eb..672c77539af 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -10,7 +10,6 @@ - cronjob:prune_old_events - cronjob:remove_expired_group_links - cronjob:remove_expired_members -- cronjob:remove_old_web_hook_logs - cronjob:remove_unreferenced_lfs_objects - cronjob:repository_archive_cache - cronjob:repository_check_dispatch @@ -29,6 +28,7 @@ - gcp_cluster:wait_for_cluster_creation - gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_platform_configure +- gcp_cluster:cluster_project_configure - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -132,3 +132,5 @@ - create_note_diff_file - delete_diff_files - detect_repository_languages +- repository_cleanup +- delete_stored_files diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb index c1283e9b2fc..4a9becf0ca7 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -7,7 +7,7 @@ class ArchiveTraceWorker # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) Ci::Build.without_archived_trace.find_by(id: job_id).try do |job| - job.trace.archive! + Ci::ArchiveTraceService.new.execute(job) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 7443aad1380..f65ff239866 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -11,21 +11,9 @@ module Ci # This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL # More details in https://gitlab.com/gitlab-org/gitlab-ce/issues/36791 Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| - begin - build.trace.archive! - rescue ::Gitlab::Ci::Trace::AlreadyArchivedError - rescue => e - failed_archive_counter.increment - Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}" - end + Ci::ArchiveTraceService.new.execute(build) end end # rubocop: enable CodeReuse/ActiveRecord - - private - - def failed_archive_counter - @failed_archive_counter ||= Gitlab::Metrics.counter(:job_trace_archive_failed_total, "Counter of failed attempts of traces archiving") - end end end diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_platform_configure_worker.rb index 8f3689f0166..aa7570caa79 100644 --- a/app/workers/cluster_platform_configure_worker.rb +++ b/app/workers/cluster_platform_configure_worker.rb @@ -6,17 +6,7 @@ class ClusterPlatformConfigureWorker def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| - next unless cluster.cluster_project - - kubernetes_namespace = cluster.find_or_initialize_kubernetes_namespace(cluster.cluster_project) - - Clusters::Gcp::Kubernetes::CreateOrUpdateNamespaceService.new( - cluster: cluster, - kubernetes_namespace: kubernetes_namespace - ).execute + Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster) end - - rescue ::Kubeclient::HttpError => err - Rails.logger.error "Failed to create/update Kubernetes namespace for cluster_id: #{cluster_id} with error: #{err.message}" end end diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb new file mode 100644 index 00000000000..497e57c0d0b --- /dev/null +++ b/app/workers/cluster_project_configure_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ClusterProjectConfigureWorker + include ApplicationWorker + include ClusterQueue + + def perform(project_id) + project = Project.find(project_id) + + ::Clusters::RefreshService.create_or_update_namespaces_for_project(project) + end +end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 59e6bc2c97d..e2dee315cde 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -24,7 +24,7 @@ module Gitlab def find_project(id) # If the project has been marked as failed we want to bail out # automatically. - Project.import_started.find_by(id: id) + Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb index 22bdf441d6b..2baf768bfd1 100644 --- a/app/workers/concerns/project_import_options.rb +++ b/app/workers/concerns/project_import_options.rb @@ -18,7 +18,7 @@ module ProjectImportOptions "import" end - project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.") + project.import_state.mark_as_failed(_("Every %{action} attempt has failed: %{job_error_message}. Please try again.") % { action: action, job_error_message: job['error_message'] }) Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}" end end diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 46a133db2a1..4462bc51a24 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -2,11 +2,11 @@ # Used in EE by mirroring module ProjectStartImport - def start(project) - if project.import_started? && project.import_jid == self.jid + def start(import_state) + if import_state.started? && import_state.jid == self.jid return true end - project.import_start + import_state.start end end diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb new file mode 100644 index 00000000000..ff7931849d8 --- /dev/null +++ b/app/workers/delete_stored_files_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class DeleteStoredFilesWorker + include ApplicationWorker + + def perform(class_name, keys) + klass = begin + class_name.constantize + rescue NameError + nil + end + + unless klass + message = "Unknown class '#{class_name}'" + logger.error(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message)) + return + end + + klass.new(logger: logger).delete_keys(keys) + end +end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 2b49860025a..0b3437a8a33 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -31,7 +31,7 @@ module Gitlab # next_stage - The name of the next stage to start when all jobs have been # completed. def perform(project_id, waiters, next_stage) - return unless (project = find_project(project_id)) + return unless import_state = find_import_state(project_id) new_waiters = wait_for_jobs(waiters) @@ -41,7 +41,7 @@ module Gitlab # the pressure on Redis. We _only_ do this once all jobs are done so # we don't get stuck forever if one or more jobs failed to notify the # JobWaiter. - project.refresh_import_jid_expiration + import_state.refresh_jid_expiration STAGES.fetch(next_stage.to_sym).perform_async(project_id) else @@ -64,11 +64,8 @@ module Gitlab end # rubocop: disable CodeReuse/ActiveRecord - def find_project(id) - # TODO: Only select the JID - # This is due to the fact that the JID could be present in either the project record or - # its associated import_state record - Project.import_started.find_by(id: id) + def find_import_state(project_id) + ProjectImportState.select(:jid).with_status(:started).find_by(project_id: project_id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb index 65473026b4c..76723e4a61f 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -16,12 +16,13 @@ module Gitlab # project_id - The ID of the project that is being imported. # check_job_id - The ID of the job for which to check the status. def perform(project_id, check_job_id) - return unless (project = find_project(project_id)) + import_state = find_import_state(project_id) + return unless import_state if SidekiqStatus.running?(check_job_id) # As long as the repository is being cloned we want to keep refreshing # the import JID status. - project.refresh_import_jid_expiration + import_state.refresh_jid_expiration self.class.perform_in_the_future(project_id, check_job_id) end @@ -31,11 +32,10 @@ module Gitlab end # rubocop: disable CodeReuse/ActiveRecord - def find_project(id) - # TODO: Only select the JID - # This is due to the fact that the JID could be present in either the project record or - # its associated import_state record - Project.import_started.find_by(id: id) + def find_import_state(project_id) + ProjectImportState.select(:jid) + .with_status(:started) + .find_by(project_id: project_id) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 5726fbb573d..ccfed2ae187 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -23,7 +23,7 @@ module Gitlab klass.new(project, client).execute end - project.refresh_import_jid_expiration + project.import_state.refresh_jid_expiration ImportPullRequestsWorker.perform_async(project.id) end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index 1c5a7139802..37a7a7f4ba0 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -15,7 +15,7 @@ module Gitlab .new(project, client) .execute - project.refresh_import_jid_expiration + project.import_state.refresh_jid_expiration AdvanceStageWorker.perform_async( project.id, diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 42f5b945a75..98f9f45e608 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -8,11 +8,18 @@ class NewNoteWorker # rubocop: disable CodeReuse/ActiveRecord def perform(note_id, _params = {}) if note = Note.find_by(id: note_id) - NotificationService.new.new_note(note) + NotificationService.new.new_note(note) unless skip_notification?(note) Notes::PostProcessService.new(note).execute else Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") end end + + private + + # EE-only method + def skip_notification?(note) + false + end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 85d1ffe0fa9..ac4e9710f33 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -9,18 +9,36 @@ class PipelineScheduleWorker Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) .preload(:owner, :project).find_each do |schedule| begin - pipeline = Ci::CreatePipelineService.new(schedule.project, - schedule.owner, - ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) - - schedule.deactivate! unless pipeline.persisted? + Ci::CreatePipelineService.new(schedule.project, + schedule.owner, + ref: schedule.ref) + .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule) rescue => e - Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" + error(schedule, e) ensure schedule.schedule_next_run! end end end # rubocop: enable CodeReuse/ActiveRecord + + private + + def error(schedule, error) + failed_creation_counter.increment + + Rails.logger.error "Failed to create a scheduled pipeline. " \ + "schedule_id: #{schedule.id} message: #{error.message}" + + Gitlab::Sentry + .track_exception(error, + issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231', + extra: { schedule_id: schedule.id }) + end + + def failed_creation_counter + @failed_creation_counter ||= + Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total, + "Counter of failed attempts of pipeline schedule creation") + end end diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb deleted file mode 100644 index 0f486f8991d..00000000000 --- a/app/workers/remove_old_web_hook_logs_worker.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class RemoveOldWebHookLogsWorker - include ApplicationWorker - include CronjobQueue - - WEB_HOOK_LOG_LIFETIME = 2.days - - # rubocop: disable DestroyAll - def perform - WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME]) - end - # rubocop: enable DestroyAll -end diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb new file mode 100644 index 00000000000..aa26c173a72 --- /dev/null +++ b/app/workers/repository_cleanup_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class RepositoryCleanupWorker + include ApplicationWorker + + sidekiq_options retry: 3 + + sidekiq_retries_exhausted do |msg, err| + next if err.is_a?(ActiveRecord::RecordNotFound) + + args = msg['args'] + [msg['error_message']] + + new.perform_failure(*args) + end + + def perform(project_id, user_id) + project = Project.find(project_id) + user = User.find(user_id) + + Projects::CleanupService.new(project, user).execute + + notification_service.repository_cleanup_success(project, user) + end + + def perform_failure(project_id, user_id, error) + project = Project.find(project_id) + user = User.find(user_id) + + # Ensure the file is removed + project.bfg_object_map.remove! + notification_service.repository_cleanup_failure(project, user, error) + end + + private + + def notification_service + @notification_service ||= NotificationService.new + end +end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 68ec66e8499..7eae07d3f6b 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -12,7 +12,7 @@ class RepositoryForkWorker source_project = target_project.forked_from_project unless source_project - return target_project.mark_import_as_failed('Source project cannot be found.') + return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end fork_repository(target_project, source_project.repository_storage, source_project.disk_path) @@ -33,7 +33,7 @@ class RepositoryForkWorker end def start_fork(project) - return true if start(project) + return true if start(project.import_state) Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") false diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 82189a3c9f5..59691f48a39 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -34,14 +34,14 @@ class RepositoryImportWorker attr_reader :project def start_import - return true if start(project) + return true if start(project.import_state) Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") false end def fail_import(message) - project.mark_import_as_failed(message) + project.import_state.mark_as_failed(message) end def template_import? diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 667a4121131..c8a186ba4ce 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -63,6 +63,6 @@ class StuckImportJobsWorker # rubocop: enable CodeReuse/ActiveRecord def error_message - "Import timed out. Import took longer than #{IMPORT_JOBS_EXPIRATION} seconds" + _("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION } end end diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 9ce51662969..e8494ffa002 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -6,10 +6,11 @@ class UpdateHeadPipelineForMergeRequestWorker queue_namespace :pipeline_processing - # rubocop: disable CodeReuse/ActiveRecord def perform(merge_request_id) merge_request = MergeRequest.find(merge_request_id) - pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last + + sha = merge_request.diff_head_sha + pipeline = merge_request.all_pipelines(shas: sha).first return unless pipeline && pipeline.latest? @@ -21,7 +22,6 @@ class UpdateHeadPipelineForMergeRequestWorker merge_request.update_attribute(:head_pipeline_id, pipeline.id) end - # rubocop: enable CodeReuse/ActiveRecord def log_error_message_for(merge_request) Rails.logger.error( |