diff options
author | Douwe Maan <douwe@gitlab.com> | 2018-11-06 15:43:24 +0000 |
---|---|---|
committer | Douwe Maan <douwe@gitlab.com> | 2018-11-06 15:43:24 +0000 |
commit | 1208d55206128266690f46f0165df0fc10c24941 (patch) | |
tree | 93fbdde5a5db6cdd8f79f2806707dab093985aa2 /app | |
parent | d171ff60168cd55b6d7b9ee920269f44a26e577e (diff) | |
parent | d0c58a97c8a053c0beec7c13c1c6ec5042120ef1 (diff) | |
download | gitlab-ce-1208d55206128266690f46f0165df0fc10c24941.tar.gz |
Merge branch 'master' into 'refactor-snippets-finder'refactor-snippets-finder
# Conflicts:
# spec/models/project_spec.rb
Diffstat (limited to 'app')
185 files changed, 1910 insertions, 906 deletions
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index bb3b2865934..669630edcab 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -30,6 +30,7 @@ class ListIssue { this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; + this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; if (obj.project) { this.project = new IssueProject(obj.project); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index a2aa3d197e3..82532539c9c 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -2,9 +2,15 @@ import PipelinesService from '../../pipelines/services/pipelines_service'; import PipelineStore from '../../pipelines/stores/pipelines_store'; import pipelinesMixin from '../../pipelines/mixins/pipelines'; +import TablePagination from '../../vue_shared/components/table_pagination.vue'; +import { getParameterByName } from '../../lib/utils/common_utils'; +import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; export default { - mixins: [pipelinesMixin], + components: { + TablePagination, + }, + mixins: [pipelinesMixin, CIPaginationMixin], props: { endpoint: { type: String, @@ -35,6 +41,8 @@ export default { return { store, state: store.state, + page: getParameterByName('page') || '1', + requestData: {}, }; }, @@ -48,11 +56,14 @@ export default { }, created() { this.service = new PipelinesService(this.endpoint); + this.requestData = { page: this.page }; }, methods: { successCallback(resp) { // depending of the endpoint the response can either bring a `pipelines` key or not. const pipelines = resp.data.pipelines || resp.data; + + this.store.storePagination(resp.headers); this.setCommonData(pipelines); const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { @@ -97,5 +108,11 @@ export default { :view-type="viewType" /> </div> + + <table-pagination + v-if="shouldRenderPagination" + :change="onChangePage" + :page-info="state.pageInfo" + /> </div> </template> diff --git a/app/assets/javascripts/commons/gitlab_ui.js b/app/assets/javascripts/commons/gitlab_ui.js index e93e1f5ea2c..6c18a0fd390 100644 --- a/app/assets/javascripts/commons/gitlab_ui.js +++ b/app/assets/javascripts/commons/gitlab_ui.js @@ -1,7 +1,4 @@ import Vue from 'vue'; -import { GlProgressBar, GlLoadingIcon, GlTooltipDirective } from '@gitlab-org/gitlab-ui'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; -Vue.component('gl-progress-bar', GlProgressBar); Vue.component('gl-loading-icon', GlLoadingIcon); - -Vue.directive('gl-tooltip', GlTooltipDirective); diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 1b59777f901..254bc235691 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -3,6 +3,7 @@ import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { pluralize, truncate } from '~/lib/utils/text_utility'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import { GlTooltipDirective } from '@gitlab-org/gitlab-ui'; import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; export default { @@ -10,6 +11,9 @@ export default { Icon, UserAvatarImage, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { discussions: { type: Array, 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 6eff3013dcd..f4a9be19496 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -167,7 +167,7 @@ export default { <button v-if="shouldShowCommentButton" type="button" - class="add-diff-note js-add-diff-note-button" + class="add-diff-note js-add-diff-note-button qa-diff-comment" title="Add a comment to this line" @click="handleCommentButton" > 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 62fa34e835a..542acd3d930 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -102,7 +102,7 @@ export default { :line-type="newLineType" :is-bottom="isBottom" :is-hover="isHover" - class="diff-line-num new_line" + class="diff-line-num new_line qa-new-diff-line" /> <td :class="line.type" diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js index 5bea47f23c5..d8d0fa1fac4 100644 --- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js +++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js @@ -31,7 +31,7 @@ class DirtySubmitForm { updateDirtyInput(event) { const input = event.target; - if (!input.dataset.dirtySubmitOriginalValue) return; + if (!input.dataset.isDirtySubmitInput) return; this.updateDirtyInputs(input); this.toggleSubmission(); @@ -65,6 +65,7 @@ class DirtySubmitForm { } static initInput(element) { + element.dataset.isDirtySubmitInput = true; element.dataset.dirtySubmitOriginalValue = DirtySubmitForm.inputCurrentValue(element); } diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 2bc168a6b02..0a3ae384afa 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,4 +1,6 @@ <script> +import { s__, sprintf } from '~/locale'; +import { formatTime } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; import tooltip from '../../vue_shared/directives/tooltip'; @@ -28,10 +30,24 @@ export default { }, }, methods: { - onClickAction(endpoint) { + onClickAction(action) { + if (action.scheduledAt) { + const confirmationMessage = sprintf( + s__( + "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", + ), + { jobName: action.name }, + ); + // https://gitlab.com/gitlab-org/gitlab-ce/issues/52156 + // eslint-disable-next-line no-alert + if (!window.confirm(confirmationMessage)) { + return; + } + } + this.isLoading = true; - eventHub.$emit('postAction', { endpoint }); + eventHub.$emit('postAction', { endpoint: action.playPath }); }, isActionDisabled(action) { @@ -41,6 +57,11 @@ export default { return !action.playable; }, + + remainingTime(action) { + const remainingMilliseconds = new Date(action.scheduledAt).getTime() - Date.now(); + return formatTime(Math.max(0, remainingMilliseconds)); + }, }, }; </script> @@ -54,7 +75,7 @@ export default { :aria-label="title" :disabled="isLoading" type="button" - class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container" + class="dropdown btn btn-default dropdown-new js-environment-actions-dropdown" data-container="body" data-toggle="dropdown" > @@ -75,12 +96,19 @@ export default { :class="{ disabled: isActionDisabled(action) }" :disabled="isActionDisabled(action)" type="button" - class="js-manual-action-link no-btn btn" - @click="onClickAction(action.play_path)" + class="js-manual-action-link no-btn btn d-flex align-items-center" + @click="onClickAction(action)" > - <span> + <span class="flex-fill"> {{ action.name }} </span> + <span + v-if="action.scheduledAt" + class="text-secondary" + > + <icon name="clock" /> + {{ remainingTime(action) }} + </span> </button> </li> </ul> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index b62a5bb1940..41f59447905 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -13,6 +13,7 @@ import TerminalButtonComponent from './environment_terminal_button.vue'; import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit.vue'; import eventHub from '../event_hub'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; /** * Environment Item Component @@ -74,21 +75,6 @@ export default { }, /** - * Verifies is the given environment has manual actions. - * Used to verify if we should render them or nor. - * - * @returns {Boolean|Undefined} - */ - hasManualActions() { - return ( - this.model && - this.model.last_deployment && - this.model.last_deployment.manual_actions && - this.model.last_deployment.manual_actions.length > 0 - ); - }, - - /** * Checkes whether the environment is protected. * (`is_protected` currently only set in EE) * @@ -154,23 +140,20 @@ export default { return ''; }, - /** - * Returns the manual actions with the name parsed. - * - * @returns {Array.<Object>|Undefined} - */ - manualActions() { - if (this.hasManualActions) { - return this.model.last_deployment.manual_actions.map(action => { - const parsedAction = { - name: humanize(action.name), - play_path: action.play_path, - playable: action.playable, - }; - return parsedAction; - }); + actions() { + if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) { + return []; } - return []; + + const { manualActions, scheduledActions } = convertObjectPropsToCamelCase( + this.model.last_deployment, + { deep: true }, + ); + const combinedActions = (manualActions || []).concat(scheduledActions || []); + return combinedActions.map(action => ({ + ...action, + name: humanize(action.name), + })); }, /** @@ -443,7 +426,7 @@ export default { displayEnvironmentActions() { return ( - this.hasManualActions || + this.actions.length > 0 || this.externalURL || this.monitoringUrl || this.canStopEnvironment || @@ -619,8 +602,8 @@ export default { /> <actions-component - v-if="hasManualActions && canCreateDeployment" - :actions="manualActions" + v-if="actions.length > 0" + :actions="actions" /> <terminal-button-component diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index 17fd5321642..93c89411b4a 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -1,10 +1,12 @@ <script> +import { GlLink } from '@gitlab-org/gitlab-ui'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { components: { TimeagoTooltip, + GlLink, }, mixins: [timeagoMixin], props: { @@ -53,16 +55,16 @@ export default { class="btn-group d-flex" role="group" > - <a + <gl-link v-if="artifact.keep_path" :href="artifact.keep_path" class="js-keep-artifacts btn btn-sm btn-default" data-method="post" > {{ s__('Job|Keep') }} - </a> + </gl-link> - <a + <gl-link v-if="artifact.download_path" :href="artifact.download_path" class="js-download-artifacts btn btn-sm btn-default" @@ -70,15 +72,15 @@ export default { rel="nofollow" > {{ s__('Job|Download') }} - </a> + </gl-link> - <a + <gl-link v-if="artifact.browse_path" :href="artifact.browse_path" class="js-browse-artifacts btn btn-sm btn-default" > {{ s__('Job|Browse') }} - </a> + </gl-link> </div> </div> </template> diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 7d51f6afd10..06fe23fedce 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -1,9 +1,11 @@ <script> +import { GlLink } from '@gitlab-org/gitlab-ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { components: { ClipboardButton, + GlLink, }, props: { commit: { @@ -31,10 +33,10 @@ export default { <p> {{ __('Commit') }} - <a + <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit" - >{{ commit.short_id }}</a> + >{{ commit.short_id }}</gl-link> <clipboard-button :text="commit.short_id" @@ -42,11 +44,11 @@ export default { css-class="btn btn-clipboard btn-transparent" /> - <a + <gl-link v-if="mergeRequest" :href="mergeRequest.path" class="js-link-commit link-commit" - >!{{ mergeRequest.iid }}</a> + >!{{ mergeRequest.iid }}</gl-link> </p> <p class="build-light-text append-bottom-0"> diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index ee5ceb99b0a..be7425c2d25 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -1,5 +1,10 @@ <script> +import { GlLink } from '@gitlab-org/gitlab-ui'; + export default { + components: { + GlLink, + }, props: { illustrationPath: { type: String, @@ -62,13 +67,13 @@ export default { v-if="action" class="text-center" > - <a + <gl-link :href="action.path" :data-method="action.method" class="js-job-empty-state-action btn btn-primary" > {{ action.button_title }} - </a> + </gl-link> </div> </div> </div> diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue index 5ffbfb6e19a..d80e905c68e 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/erased_block.vue @@ -1,10 +1,12 @@ <script> import _ from 'underscore'; +import { GlLink } from '@gitlab-org/gitlab-ui'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { TimeagoTooltip, + GlLink, }, props: { user: { @@ -29,9 +31,9 @@ export default { <div class="erased alert alert-warning"> <template v-if="isErasedByUser"> {{ s__("Job|Job has been erased by") }} - <a :href="user.web_url"> + <gl-link :href="user.web_url"> {{ user.username }} - </a> + </gl-link> </template> <template v-else> {{ s__("Job|Job has been erased") }} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 3cabbfc6e27..6e95e3d16f8 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -1,6 +1,7 @@ <script> import _ from 'underscore'; import { mapGetters, mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import bp from '~/breakpoints'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; @@ -23,6 +24,7 @@ export default { EmptyState, EnvironmentsBlock, ErasedBlock, + GlLoadingIcon, Log, LogTopBar, StuckBlock, diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 6486b25c8a7..cdac8a391d1 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -1,15 +1,16 @@ <script> +import { GlTooltipDirective, GlLink } from '@gitlab-org/gitlab-ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { components: { CiIcon, Icon, + GlLink, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { job: { @@ -37,11 +38,10 @@ export default { active: isActive }" > - <a - v-tooltip + <gl-link + v-gl-tooltip :href="job.status.details_path" :title="tooltipText" - data-container="body" data-boundary="viewport" class="js-job-link" > @@ -60,6 +60,6 @@ export default { name="retry" class="js-retry-icon" /> - </a> + </gl-link> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 94ab1b16c84..eeefa33264f 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -1,7 +1,7 @@ <script> +import { GlTooltipDirective, GlLink, GlButton } from '@gitlab-org/gitlab-ui'; import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { sprintf } from '~/locale'; import scrollDown from '../svg/scroll_down.svg'; @@ -9,9 +9,11 @@ import scrollDown from '../svg/scroll_down.svg'; export default { components: { Icon, + GlLink, + GlButton, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, scrollDown, props: { @@ -73,76 +75,70 @@ export default { <template v-if="isTraceSizeVisible"> {{ jobLogSize }} - <a + <gl-link v-if="rawPath" :href="rawPath" class="js-raw-link raw-link" > {{ s__("Job|Complete Raw") }} - </a> + </gl-link> </template> </div> <!-- eo truncate information --> <div class="controllers float-right"> <!-- links --> - <a + <gl-link v-if="rawPath" - v-tooltip + v-gl-tooltip.body :title="s__('Job|Show complete raw')" :href="rawPath" class="js-raw-link-controller controllers-buttons" - data-container="body" > <icon name="doc-text" /> - </a> + </gl-link> - <a + <gl-link v-if="erasePath" - v-tooltip + v-gl-tooltip.body :title="s__('Job|Erase job log')" :href="erasePath" :data-confirm="__('Are you sure you want to erase this build?')" class="js-erase-link controllers-buttons" - data-container="body" data-method="post" > <icon name="remove" /> - </a> + </gl-link> <!-- eo links --> <!-- scroll buttons --> <div - v-tooltip + v-gl-tooltip :title="s__('Job|Scroll to top')" class="controllers-buttons" - data-container="body" > - <button + <gl-button :disabled="isScrollTopDisabled" type="button" class="js-scroll-top btn-scroll btn-transparent btn-blank" @click="handleScrollToTop" > - <icon name="scroll_up"/> - </button> + <icon name="scroll_up" /> + </gl-button> </div> <div - v-tooltip + v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="controllers-buttons" - data-container="body" > - <button + <gl-button :disabled="isScrollBottomDisabled" - type="button" class="js-scroll-bottom btn-scroll btn-transparent btn-blank" :class="{ animate: isScrollingDown }" @click="handleScrollToBottom" v-html="$options.scrollDown" - > - </button> + /> </div> <!-- eo scroll buttons --> </div> diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue index aeafe98a70b..cfedb38e17a 100644 --- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue +++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue @@ -1,6 +1,11 @@ <script> +import { GlLink } from '@gitlab-org/gitlab-ui'; + export default { name: 'SidebarDetailRow', + components: { + GlLink, + }, props: { title: { type: String, @@ -41,7 +46,7 @@ export default { v-if="hasHelpURL" class="help-button float-right" > - <a + <gl-link :href="helpUrl" target="_blank" rel="noopener noreferrer nofollow" @@ -50,7 +55,7 @@ export default { class="fa fa-question-circle" aria-hidden="true" ></i> - </a> + </gl-link> </span> </p> </template> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index 1d5789b175a..ca4bf471363 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -1,8 +1,12 @@ <script> +import { GlLink } from '@gitlab-org/gitlab-ui'; /** * Renders Stuck Runners block for job's view. */ export default { + components: { + GlLink, + }, props: { hasNoRunnersForProject: { type: Boolean, @@ -52,12 +56,12 @@ export default { </p> {{ __("Go to") }} - <a + <gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path" > {{ __("Runners page") }} - </a> + </gl-link> </div> </template> diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 5457604b3b9..c0a76814102 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -59,7 +59,6 @@ export default class LabelsSelect { $toggleText = $dropdown.find('.dropdown-toggle-text'); namespacePath = $dropdown.data('namespacePath'); projectPath = $dropdown.data('projectPath'); - labelUrl = $dropdown.data('labels'); issueUpdateURL = $dropdown.data('issueUpdate'); selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { @@ -168,6 +167,7 @@ export default class LabelsSelect { $dropdown.glDropdown({ showMenuAbove: showMenuAbove, data: function(term, callback) { + labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then(res => { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index b980e43b898..554db102027 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -390,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" name="button" type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle" + 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"> @@ -422,7 +422,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> <button type="button" - class="btn btn-transparent" + class="btn btn-transparent qa-discussion-option" @click.prevent="setNoteType('discussion')"> <i aria-hidden="true" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 6e8f43048d1..affa2d1b574 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,7 +1,8 @@ <script> import $ from 'jquery'; -import Icon from '~/vue_shared/components/icon.vue'; import { mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE } from '../constants'; export default { components: { @@ -12,14 +13,17 @@ export default { type: Array, required: true, }, - defaultValue: { + selectedValue: { type: Number, default: null, required: false, }, }, data() { - return { currentValue: this.defaultValue }; + return { + currentValue: this.selectedValue, + defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + }; }, computed: { ...mapGetters(['getNotesDataByProp']), @@ -28,8 +32,11 @@ export default { return this.filters.find(filter => filter.value === this.currentValue); }, }, + mounted() { + this.toggleCommentsForm(); + }, methods: { - ...mapActions(['filterDiscussion']), + ...mapActions(['filterDiscussion', 'setCommentsDisabled']), selectFilter(value) { const filter = parseInt(value, 10); @@ -39,6 +46,10 @@ export default { if (filter === this.currentValue) return; this.currentValue = filter; this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); + this.toggleCommentsForm(); + }, + toggleCommentsForm() { + this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, }, }; @@ -73,6 +84,10 @@ export default { > {{ filter.title }} </button> + <div + v-if="filter.value === defaultValue" + class="dropdown-divider" + ></div> </li> </ul> </div> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 38c43e5fe08..31ee8fed984 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -187,7 +187,7 @@ export default { :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" +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()" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index c5fdfa1d47c..6293dd5b7e1 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -369,7 +369,7 @@ Please check your network connection and try again.`; role="group"> <button type="button" - class="js-vue-discussion-reply btn btn-text-field mr-2" + class="js-vue-discussion-reply btn btn-text-field mr-2 qa-discussion-reply" title="Add a reply" @click="showReplyForm">Reply...</button> </div> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 7514ce8a1eb..ed5ac112dc0 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -60,6 +60,7 @@ export default { 'getNotesDataByProp', 'discussionCount', 'isLoading', + 'commentsDisabled', ]), noteableType() { return this.noteableData.noteableType; @@ -206,6 +207,7 @@ export default { </ul> <comment-form + v-if="!commentsDisabled" :noteable-type="noteableType" :markdown-version="markdownVersion" /> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 2c3e07c0506..3147dc64c27 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -15,6 +15,8 @@ export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; +export const HISTORY_ONLY_FILTER_VALUE = 2; +export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js index 06eadaeea0e..5c5f38a3fb0 100644 --- a/app/assets/javascripts/notes/discussion_filters.js +++ b/app/assets/javascripts/notes/discussion_filters.js @@ -6,7 +6,7 @@ export default store => { if (discussionFilterEl) { const { defaultFilter, notesFilters } = discussionFilterEl.dataset; - const defaultValue = defaultFilter ? parseInt(defaultFilter, 10) : null; + const selectedValue = defaultFilter ? parseInt(defaultFilter, 10) : null; const filterValues = notesFilters ? JSON.parse(notesFilters) : {}; const filters = Object.keys(filterValues).map(entry => ({ title: entry, @@ -24,7 +24,7 @@ export default store => { return createElement('discussion-filter', { props: { filters, - defaultValue, + selectedValue, }, }); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index b5dd49bc6c9..88739ffb083 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -364,5 +364,9 @@ export const filterDiscussion = ({ dispatch }, { path, filter }) => { }); }; +export const setCommentsDisabled = ({ commit }, data) => { + commit(types.DISABLE_COMMENTS, data); +}; + // 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 e4f36154fcd..8df95c279eb 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -192,5 +192,7 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { return getters.unresolvedDiscussionsIdsByDate[0]; }; +export const commentsDisabled = state => state.commentsDisabled; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 400142668ea..8aea269ea7d 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -21,6 +21,7 @@ export default () => ({ noteableData: { current_user: {}, }, + commentsDisabled: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 2fa53aef1d4..dfbf3b7b34b 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -15,6 +15,7 @@ export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; +export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 65085452139..c8d9e196103 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -225,4 +225,8 @@ export default { discussion.truncated_diff_lines = diffLines; }, + + [types.DISABLE_COMMENTS](state, value) { + state.commentsDisabled = value; + }, }; diff --git a/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js b/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js deleted file mode 100644 index d4f34e32a48..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; - -document.addEventListener('DOMContentLoaded', () => { - initGkeDropdowns(); -}); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js new file mode 100644 index 00000000000..1b57c67f16b --- /dev/null +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +document.addEventListener('DOMContentLoaded', () => { + const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); + remainingTimeElements.forEach( + el => + new Vue({ + ...GlCountdown, + el, + propsData: { + endDateString: el.dateTime, + }, + }), + ); +}); diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index ea526cf1309..fcd8a54c9c1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -155,14 +155,6 @@ export default { ); }, - shouldRenderPagination() { - return ( - !this.isLoading && - this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage - ); - }, - emptyTabMessage() { const { scopes } = this.$options; const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; @@ -232,36 +224,6 @@ export default { this.setCommonData(resp.data.pipelines); } }, - /** - * Handles URL and query parameter changes. - * When the user uses the pagination or the tabs, - * - update URL - * - Make API request to the server with new parameters - * - Update the polling function - * - Update the internal state - */ - updateContent(parameters) { - this.updateInternalState(parameters); - - // fetch new data - return this.service - .getPipelines(this.requestData) - .then(response => { - this.isLoading = false; - this.successCallback(response); - - // restart polling - this.poll.restart({ data: this.requestData }); - }) - .catch(() => { - this.isLoading = false; - this.errorCallback(); - - // restart polling - this.poll.restart({ data: this.requestData }); - }); - }, - handleResetRunnersCache(endpoint) { this.isResetCacheButtonLoading = true; diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index a7507fb3b6f..07a4af3e61e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -29,7 +29,7 @@ export default { if (action.scheduled_at) { const confirmationMessage = sprintf( s__( - "DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes.", + "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", ), { jobName: action.name }, ); diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 8929b397f6c..85781f548c6 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -23,6 +23,15 @@ export default { hasMadeRequest: false, }; }, + computed: { + shouldRenderPagination() { + return ( + !this.isLoading && + this.state.pipelines.length && + this.state.pageInfo.total > this.state.pageInfo.perPage + ); + }, + }, beforeMount() { this.poll = new Poll({ resource: this.service, @@ -65,6 +74,35 @@ export default { this.poll.stop(); }, methods: { + /** + * Handles URL and query parameter changes. + * When the user uses the pagination or the tabs, + * - update URL + * - Make API request to the server with new parameters + * - Update the polling function + * - Update the internal state + */ + updateContent(parameters) { + this.updateInternalState(parameters); + + // fetch new data + return this.service + .getPipelines(this.requestData) + .then(response => { + this.isLoading = false; + this.successCallback(response); + + // restart polling + this.poll.restart({ data: this.requestData }); + }) + .catch(() => { + this.isLoading = false; + this.errorCallback(); + + // restart polling + this.poll.restart({ data: this.requestData }); + }); + }, updateTable() { // Cancel ongoing request if (this.isMakingRequest) { diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue index 3b425ee2fed..f4243522ef8 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/reports/components/issues_list.vue @@ -1,18 +1,31 @@ <script> -import IssuesBlock from '~/reports/components/report_issues.vue'; -import { STATUS_SUCCESS, STATUS_FAILED, STATUS_NEUTRAL } from '~/reports/constants'; +import ReportItem from '~/reports/components/report_item.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; + +const wrapIssueWithState = (status, isNew = false) => issue => ({ + status: issue.status || status, + isNew, + issue, +}); /** * Renders block of issues */ - export default { components: { - IssuesBlock, + SmartVirtualList, + ReportItem, }, - success: STATUS_SUCCESS, - failed: STATUS_FAILED, - neutral: STATUS_NEUTRAL, + // Typical height of a report item in px + typicalReportItemHeight: 32, + /* + The maximum amount of shown issues. This is calculated by + ( max-height of report-block-list / typicalReportItemHeight ) + some safety margin + We will use VirtualList if we have more items than this number. + For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly. + */ + maxShownReportItems: 20, props: { newIssues: { type: Array, @@ -40,42 +53,34 @@ export default { default: '', }, }, + computed: { + issuesWithState() { + return [ + ...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)), + ...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)), + ...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)), + ...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)), + ]; + }, + }, }; </script> <template> - <div class="report-block-container"> - - <issues-block - v-if="newIssues.length" - :component="component" - :issues="newIssues" - class="js-mr-code-new-issues" - status="failed" - is-new - /> - - <issues-block - v-if="unresolvedIssues.length" - :component="component" - :issues="unresolvedIssues" - :status="$options.failed" - class="js-mr-code-new-issues" - /> - - <issues-block - v-if="neutralIssues.length" - :component="component" - :issues="neutralIssues" - :status="$options.neutral" - class="js-mr-code-non-issues" - /> - - <issues-block - v-if="resolvedIssues.length" + <smart-virtual-list + :length="issuesWithState.length" + :remain="$options.maxShownReportItems" + :size="$options.typicalReportItemHeight" + class="report-block-container" + wtag="ul" + wclass="report-block-list" + > + <report-item + v-for="(wrapped, index) in issuesWithState" + :key="index" + :issue="wrapped.issue" + :status="wrapped.status" :component="component" - :issues="resolvedIssues" - :status="$options.success" - class="js-mr-code-resolved-issues" + :is-new="wrapped.isNew" /> - </div> + </smart-virtual-list> </template> diff --git a/app/assets/javascripts/reports/components/report_issues.vue b/app/assets/javascripts/reports/components/report_item.vue index a2a03945ae3..01e6d357a21 100644 --- a/app/assets/javascripts/reports/components/report_issues.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -3,14 +3,14 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; import { components, componentNames } from '~/reports/components/issue_body'; export default { - name: 'ReportIssues', + name: 'ReportItem', components: { IssueStatusIcon, ...components, }, props: { - issues: { - type: Array, + issue: { + type: Object, required: true, }, component: { @@ -33,27 +33,21 @@ export default { }; </script> <template> - <div> - <ul class="report-block-list"> - <li - v-for="(issue, index) in issues" - :key="index" - :class="{ 'is-dismissed': issue.isDismissed }" - class="report-block-list-issue" - > - <issue-status-icon - :status="issue.status || status" - class="append-right-5" - /> + <li + :class="{ 'is-dismissed': issue.isDismissed }" + class="report-block-list-issue" + > + <issue-status-icon + :status="status" + class="append-right-5" + /> - <component - :is="component" - v-if="component" - :issue="issue" - :status="issue.status || status" - :is-new="isNew" - /> - </li> - </ul> - </div> + <component + :is="component" + v-if="component" + :issue="issue" + :status="status" + :is-new="isNew" + /> + </li> </template> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 8950ae31627..4d461baf74d 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import GfmAutoComplete from '~/gfm_auto_complete'; import { __, s__ } from '~/locale'; import Api from '~/api'; -import { GlModal } from '@gitlab-org/gitlab-ui'; +import { GlModal, GlTooltipDirective } from '@gitlab-org/gitlab-ui'; import eventHub from './event_hub'; import EmojiMenuInModal from './emoji_menu_in_modal'; @@ -16,6 +16,9 @@ export default { Icon, GlModal, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { currentEmoji: { type: String, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index e74912d628f..b145e5dc5e2 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,9 +1,13 @@ <script> import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import tooltip from '../../../vue_shared/directives/tooltip'; +import { GlProgressBar } from '@gitlab-org/gitlab-ui'; export default { name: 'TimeTrackingComparisonPane', + components: { + GlProgressBar, + }, directives: { tooltip, }, 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 57c52a2016a..2a8380f5f2b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -65,6 +65,14 @@ export default { deployedText() { return this.$options.deployedTextMap[this.deployment.status]; }, + isDeployInProgress() { + return this.deployment.status === 'running'; + }, + deployInProgressTooltip() { + return this.isDeployInProgress + ? __('Stopping this environment is currently not possible as a deployment is in progress') + : ''; + }, shouldRenderDropdown() { return ( this.enableCiEnvironmentsStatusChanges && @@ -183,15 +191,23 @@ export default { css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin" /> </template> - <loading-button + <span v-if="deployment.stop_url" - :loading="isStopping" - container-class="btn btn-default btn-sm inline prepend-left-4" - title="Stop environment" - @click="stopEnvironment" + v-tooltip + :title="deployInProgressTooltip" + class="d-inline-block" + tabindex="0" > - <icon name="stop" /> - </loading-button> + <loading-button + :loading="isStopping" + :disabled="isDeployInProgress" + :title="__('Stop environment')" + container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4" + @click="stopEnvironment" + > + <icon name="stop" /> + </loading-button> + </span> </div> </div> </div> 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 8bcabc10225..53608838f2f 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 @@ -71,6 +71,7 @@ export default { linkStart: `<a href="${this.troubleshootingDocsPath}">`, linkEnd: '</a>', }, + false, ); }, }, diff --git a/app/assets/javascripts/vue_shared/components/gl_countdown.vue b/app/assets/javascripts/vue_shared/components/gl_countdown.vue index 9327a2a4a6c..a35986b2d03 100644 --- a/app/assets/javascripts/vue_shared/components/gl_countdown.vue +++ b/app/assets/javascripts/vue_shared/components/gl_countdown.vue @@ -1,10 +1,14 @@ <script> import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; +import { GlTooltipDirective } from '@gitlab-org/gitlab-ui'; /** * Counts down to a given end date. */ export default { + directives: { + GlTooltip: GlTooltipDirective, + }, props: { endDateString: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 3ddb39730c4..27e3f314dd3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,17 +1,17 @@ <script> import $ from 'jquery'; -import Tooltip from '../../directives/tooltip'; +import { GlTooltipDirective } from '@gitlab-org/gitlab-ui'; import ToolbarButton from './toolbar_button.vue'; import Icon from '../icon.vue'; export default { - directives: { - Tooltip, - }, components: { ToolbarButton, Icon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { previewMarkdown: { type: Boolean, @@ -147,7 +147,7 @@ export default { icon="table" /> <button - v-tooltip + v-gl-tooltip aria-label="Go full screen" class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" data-container="body" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 3e89e1c1e75..91d0bbfc21c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -1,13 +1,13 @@ <script> -import tooltip from '../../directives/tooltip'; -import icon from '../icon.vue'; +import { GlTooltipDirective } from '@gitlab-org/gitlab-ui'; +import Icon from '../icon.vue'; export default { components: { - icon, + Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { buttonTitle: { @@ -43,7 +43,7 @@ export default { <template> <button - v-tooltip + v-gl-tooltip :data-md-tag="tag" :data-md-select="tagSelect" :data-md-block="tagBlock" diff --git a/app/assets/javascripts/vue_shared/components/smart_virtual_list.vue b/app/assets/javascripts/vue_shared/components/smart_virtual_list.vue new file mode 100644 index 00000000000..63034a45f77 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/smart_virtual_list.vue @@ -0,0 +1,42 @@ +<script> +import VirtualList from 'vue-virtual-scroll-list'; + +export default { + name: 'SmartVirtualList', + components: { VirtualList }, + props: { + size: { type: Number, required: true }, + length: { type: Number, required: true }, + remain: { type: Number, required: true }, + rtag: { type: String, default: 'div' }, + wtag: { type: String, default: 'div' }, + wclass: { type: String, default: null }, + }, +}; +</script> +<template> + <virtual-list + v-if="length > remain" + v-bind="$attrs" + :size="remain" + :remain="remain" + :rtag="rtag" + :wtag="wtag" + :wclass="wclass" + class="js-virtual-list" + > + <slot></slot> + </virtual-list> + <component + :is="rtag" + v-else + class="js-plain-element" + > + <component + :is="wtag" + :class="wclass" + > + <slot></slot> + </component> + </component> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js index 67a1632269e..f9e3f3df0cc 100644 --- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js @@ -14,7 +14,14 @@ export default { onChangePage(page) { /* URLS parameters are strings, we need to parse to match types */ - this.updateContent({ scope: this.scope, page: Number(page).toString() }); + const params = { + page: Number(page).toString(), + }; + + if (this.scope) { + params.scope = this.scope; + } + this.updateContent(params); }, updateInternalState(parameters) { diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index f26b1fddae5..43b7c26b272 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -348,6 +348,7 @@ @include media-breakpoint-down(xs) { width: 100%; + margin: $btn-side-margin 0; } } } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 2e7f25d975e..6f103e4e89a 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -322,15 +322,15 @@ width: $contextual-sidebar-width - 1px; transition: width $sidebar-transition-duration; position: fixed; + height: $toggle-sidebar-height; bottom: 0; - padding: $gl-padding; + padding: 0 $gl-padding; background-color: $gray-light; border: 0; border-top: 1px solid $border-color; color: $gl-text-color-secondary; display: flex; align-items: center; - line-height: 1; svg { margin-right: 8px; diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 1e93bf2b751..a20920e2503 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -39,7 +39,7 @@ svg { fill: currentColor; - $svg-sizes: 8 10 12 16 18 24 32 48 72; + $svg-sizes: 8 10 12 14 16 18 24 32 48 72; @each $svg-size in $svg-sizes { &.s#{$svg-size} { @include svg-size(#{$svg-size}px); diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index c030d75f5a4..9837b1a6bd0 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -291,7 +291,7 @@ /* * Mixin that handles the position of the controls placed on the top bar */ -@mixin build-controllers($control-font-size, $flex-direction, $with-grow, $flex-grow-size) { +@mixin build-controllers($control-font-size, $flex-direction, $with-grow, $flex-grow-size, $svg-display: 'block', $svg-top: '2px') { display: flex; font-size: $control-font-size; justify-content: $flex-direction; @@ -304,8 +304,9 @@ svg { width: 15px; height: 15px; - display: block; + display: $svg-display; fill: $gl-text-color; + top: $svg-top; } .controllers-buttons { diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 339388392df..6954e6599b1 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -147,3 +147,9 @@ table { } } } + +.top-area + .content-list { + th { + border-top: 0; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ad66a0365ed..bfcac3f1c3f 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -10,6 +10,7 @@ $sidebar-breakpoint: 1024px; $default-transition-duration: 0.15s; $contextual-sidebar-width: 220px; $contextual-sidebar-collapsed-width: 50px; +$toggle-sidebar-height: 48px; /* * Color schema @@ -268,6 +269,7 @@ $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; $project-title-row-height: 24px; +$gl-line-height: 16px; /* * Common component specific colors diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 31b258e56dd..1449723de52 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -94,7 +94,7 @@ } .controllers { - @include build-controllers(15px, center, false, 0); + @include build-controllers(15px, center, false, 0, inline, 0); } } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 19a36061c45..347fcad771a 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -44,11 +44,6 @@ margin: 0; } - .icon-play { - height: 13px; - width: 12px; - } - .external-url, .dropdown-new { color: $gl-text-color-secondary; @@ -366,7 +361,7 @@ } .arrow-shadow { - content: ""; + content: ''; position: absolute; width: 7px; height: 7px; diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index a91d44805ee..618f23d81b1 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -4,41 +4,29 @@ */ .event-item { font-size: $gl-font-size; - padding: $gl-padding-top 0 $gl-padding-top 40px; + padding: $gl-padding 0 $gl-padding 56px; border-bottom: 1px solid $white-normal; - color: $gl-text-color; + color: $gl-text-color-secondary; position: relative; - - &.event-inline { - .system-note-image { - top: 20px; - } - - .user-avatar { - top: 14px; - } - - .event-title, - .event-item-timestamp { - line-height: 40px; - } - } - - a { - color: $gl-text-color; - } + line-height: $gl-line-height; .system-note-image { position: absolute; left: 0; - top: 14px; svg { - width: 20px; - height: 20px; fill: $gl-text-color-secondary; } + } + + .system-note-image-inline { + svg { + fill: $gl-text-color-secondary; + } + } + .system-note-image, + .system-note-image-inline { &.opened-icon, &.created-icon { svg { @@ -53,16 +41,35 @@ &.accepted-icon svg { fill: $blue-300; } + + &.commented-on-icon svg { + fill: $blue-600; + } + } + + .event-user-info { + margin-bottom: $gl-padding-8; + + .author_name { + a { + color: $gl-text-color; + font-weight: $gl-font-weight-bold; + } + } } .event-title { - @include str-truncated(calc(100% - 174px)); - font-weight: $gl-font-weight-bold; - color: $gl-text-color; + .event-type { + &::first-letter { + text-transform: capitalize; + } + } } .event-body { + margin-top: $gl-padding-8; margin-right: 174px; + color: $gl-text-color; .event-note { word-wrap: break-word; @@ -92,7 +99,7 @@ } .note-image-attach { - margin-top: 4px; + margin-top: $gl-padding-4; margin-left: 0; max-width: 200px; float: none; @@ -107,7 +114,6 @@ color: $gl-gray-500; float: left; font-size: $gl-font-size; - line-height: 16px; margin-right: 5px; } } @@ -127,7 +133,9 @@ } } - &:last-child { border: 0; } + &:last-child { + border: 0; + } .event_commits { li { @@ -154,7 +162,6 @@ .event-item-timestamp { float: right; - line-height: 22px; } } @@ -177,10 +184,8 @@ .event-item { padding-left: 0; - &.event-inline { - .event-title { - line-height: 20px; - } + .event-user-info { + margin-bottom: $gl-padding-4; } .event-title { @@ -194,7 +199,8 @@ } .event-body { - margin: 0; + margin-top: $gl-padding-4; + margin-right: 0; padding-left: 0; } diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss index 86e70955389..617b3db2fae 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -39,10 +39,6 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - - svg { - vertical-align: middle; - } } .next-run-cell { @@ -52,6 +48,10 @@ a { color: $text-color; } + + svg { + vertical-align: middle; + } } .pipeline-schedules-user-callout { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index f084adaf5d3..1d691d1d8b8 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -240,6 +240,12 @@ left: 0; } + .activities-block { + .event-item { + padding-left: 40px; + } + } + @include media-breakpoint-down(xs) { .cover-block { padding-top: 20px; @@ -267,6 +273,12 @@ margin-right: 0; } } + + .activities-block { + .event-item { + padding-left: 0; + } + } } } diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb new file mode 100644 index 00000000000..250f42f3096 --- /dev/null +++ b/app/controllers/clusters/applications_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Clusters::ApplicationsController < Clusters::BaseController + before_action :cluster + before_action :authorize_create_cluster!, only: [:create] + + def create + Clusters::Applications::CreateService + .new(@cluster, current_user, create_cluster_application_params) + .execute(request) + + head :no_content + rescue Clusters::Applications::CreateService::InvalidApplicationError + render_404 + rescue StandardError + head :bad_request + end + + private + + def cluster + @cluster ||= clusterable.clusters.find(params[:id]) || render_404 + end + + def create_cluster_application_params + params.permit(:application, :hostname) + end +end diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb new file mode 100644 index 00000000000..ef42f7c4074 --- /dev/null +++ b/app/controllers/clusters/base_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Clusters::BaseController < ApplicationController + include RoutableActions + + skip_before_action :authenticate_user! + before_action :authorize_read_cluster! + + helper_method :clusterable + + private + + def cluster + @cluster ||= clusterable.clusters.find(params[:id]) + .present(current_user: current_user) + end + + def authorize_update_cluster! + access_denied! unless can?(current_user, :update_cluster, cluster) + end + + def authorize_admin_cluster! + access_denied! unless can?(current_user, :admin_cluster, cluster) + end + + def authorize_read_cluster! + access_denied! unless can?(current_user, :read_cluster, clusterable) + end + + def authorize_create_cluster! + access_denied! unless can?(current_user, :create_cluster, clusterable) + end + + def clusterable + raise NotImplementedError + end +end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb new file mode 100644 index 00000000000..f6f2060ebb5 --- /dev/null +++ b/app/controllers/clusters/clusters_controller.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +class Clusters::ClustersController < Clusters::BaseController + include RoutableActions + + before_action :cluster, except: [:index, :new, :create_gcp, :create_user] + before_action :generate_gcp_authorize_url, only: [:new] + before_action :validate_gcp_token, only: [:new] + before_action :gcp_cluster, only: [:new] + before_action :user_cluster, only: [:new] + before_action :authorize_create_cluster!, only: [:new] + before_action :authorize_update_cluster!, only: [:update] + before_action :authorize_admin_cluster!, only: [:destroy] + before_action :update_applications_status, only: [:cluster_status] + + helper_method :token_in_session + + STATUS_POLLING_INTERVAL = 10_000 + + def index + clusters = ClustersFinder.new(clusterable, current_user, :all).execute + @clusters = clusters.page(params[:page]).per(20) + end + + def new + end + + # Overridding ActionController::Metal#status is NOT a good idea + def cluster_status + respond_to do |format| + format.json do + Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL) + + render json: ClusterSerializer + .new(current_user: @current_user) + .represent_status(@cluster) + end + end + end + + def show + end + + def update + Clusters::UpdateService + .new(current_user, update_params) + .execute(cluster) + + if cluster.valid? + respond_to do |format| + format.json do + head :no_content + end + format.html do + flash[:notice] = _('Kubernetes cluster was successfully updated.') + redirect_to cluster.show_path + end + end + else + respond_to do |format| + format.json { head :bad_request } + format.html { render :show } + end + end + end + + def destroy + if cluster.destroy + flash[:notice] = _('Kubernetes cluster integration was successfully removed.') + redirect_to clusterable.index_path, status: :found + else + flash[:notice] = _('Kubernetes cluster integration was not removed.') + render :show + end + end + + def create_gcp + @gcp_cluster = ::Clusters::CreateService + .new(current_user, create_gcp_cluster_params) + .execute(access_token: token_in_session) + .present(current_user: current_user) + + if @gcp_cluster.persisted? + redirect_to @gcp_cluster.show_path + else + generate_gcp_authorize_url + validate_gcp_token + user_cluster + + render :new, locals: { active_tab: 'gcp' } + end + end + + def create_user + @user_cluster = ::Clusters::CreateService + .new(current_user, create_user_cluster_params) + .execute(access_token: token_in_session) + .present(current_user: current_user) + + if @user_cluster.persisted? + redirect_to @user_cluster.show_path + else + generate_gcp_authorize_url + validate_gcp_token + gcp_cluster + + render :new, locals: { active_tab: 'user' } + end + end + + private + + def update_params + if cluster.managed? + params.require(:cluster).permit( + :enabled, + :environment_scope, + platform_kubernetes_attributes: [ + :namespace + ] + ) + else + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + platform_kubernetes_attributes: [ + :api_url, + :token, + :ca_cert, + :namespace + ] + ) + end + end + + def create_gcp_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + provider_gcp_attributes: [ + :gcp_project_id, + :zone, + :num_nodes, + :machine_type, + :legacy_abac + ]).merge( + provider_type: :gcp, + platform_type: :kubernetes, + clusterable: clusterable.subject + ) + end + + def create_user_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + platform_kubernetes_attributes: [ + :namespace, + :api_url, + :token, + :ca_cert, + :authorization_type + ]).merge( + provider_type: :user, + platform_type: :kubernetes, + clusterable: clusterable.subject + ) + end + + def generate_gcp_authorize_url + state = generate_session_key_redirect(clusterable.new_path.to_s) + + @authorize_url = GoogleApi::CloudPlatform::Client.new( + nil, callback_google_api_auth_url, + state: state).authorize_url + rescue GoogleApi::Auth::ConfigMissingError + # no-op + end + + def gcp_cluster + @gcp_cluster = ::Clusters::Cluster.new.tap do |cluster| + cluster.build_provider_gcp + end + end + + def user_cluster + @user_cluster = ::Clusters::Cluster.new.tap do |cluster| + cluster.build_platform_kubernetes + end + end + + def validate_gcp_token + @valid_gcp_token = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + end + + def token_in_session + session[GoogleApi::CloudPlatform::Client.session_key_for_token] + end + + def expires_at_in_session + @expires_at_in_session ||= + session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] + end + + def generate_session_key_redirect(uri) + GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| + session[key] = uri + end + end + + def update_applications_status + @cluster.applications.each(&:schedule_status_update) + end +end diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb new file mode 100644 index 00000000000..f59440dbc59 --- /dev/null +++ b/app/controllers/concerns/project_unauthorized.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ProjectUnauthorized + extend ActiveSupport::Concern + + # EE would override this + def project_unauthorized_proc + # no-op + end +end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 88939b002b2..5624eb3aa45 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -3,23 +3,25 @@ module RoutableActions extend ActiveSupport::Concern - def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) + def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil, not_found_or_authorized_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) if routable_authorized?(routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else - handle_not_found_or_authorized(routable) + if not_found_or_authorized_proc + not_found_or_authorized_proc.call(routable) + end + + route_not_found unless performed? + nil end end - # This is overridden in gitlab-ee. - def handle_not_found_or_authorized(_routable) - route_not_found - end - def routable_authorized?(routable, extra_authorization_proc) + return false unless routable + action = :"read_#{routable.class.to_s.underscore}" return false unless can?(current_user, action, routable) diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index a2bdcaefa9b..e0677ce3fbc 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -3,6 +3,7 @@ class Projects::ApplicationController < ApplicationController include CookiesHelper include RoutableActions + include ProjectUnauthorized include ChecksCollaboration skip_before_action :authenticate_user! @@ -21,7 +22,7 @@ class Projects::ApplicationController < ApplicationController path = File.join(params[:namespace_id], params[:project_id] || params[:id]) auth_proc = ->(project) { !project.pending_delete? } - @project = find_routable!(Project, path, extra_authorization_proc: auth_proc) + @project = find_routable!(Project, path, extra_authorization_proc: auth_proc, not_found_or_authorized_proc: project_unauthorized_proc) end def build_canonical_path(project) diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb index bcea96bce94..c7b6218d007 100644 --- a/app/controllers/projects/clusters/applications_controller.rb +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -1,29 +1,17 @@ # frozen_string_literal: true -class Projects::Clusters::ApplicationsController < Projects::ApplicationController - before_action :cluster - before_action :authorize_read_cluster! - before_action :authorize_create_cluster!, only: [:create] +class Projects::Clusters::ApplicationsController < Clusters::ApplicationsController + include ProjectUnauthorized - def create - Clusters::Applications::CreateService - .new(@cluster, current_user, create_cluster_application_params) - .execute(request) - - head :no_content - rescue Clusters::Applications::CreateService::InvalidApplicationError - render_404 - rescue StandardError - head :bad_request - end + prepend_before_action :project private - def cluster - @cluster ||= project.clusters.find(params[:id]) || render_404 + def clusterable + @clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user) end - def create_cluster_application_params - params.permit(:application, :hostname) + def project + @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc) end end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 62adc66fb09..feda6deeaa6 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -1,224 +1,24 @@ # frozen_string_literal: true -class Projects::ClustersController < Projects::ApplicationController - before_action :cluster, except: [:index, :new, :create_gcp, :create_user] - before_action :authorize_read_cluster! - before_action :generate_gcp_authorize_url, only: [:new] - before_action :validate_gcp_token, only: [:new] - before_action :gcp_cluster, only: [:new] - before_action :user_cluster, only: [:new] - before_action :authorize_create_cluster!, only: [:new] - before_action :authorize_update_cluster!, only: [:update] - before_action :authorize_admin_cluster!, only: [:destroy] - before_action :update_applications_status, only: [:status] - helper_method :token_in_session +class Projects::ClustersController < Clusters::ClustersController + include ProjectUnauthorized - STATUS_POLLING_INTERVAL = 10_000 + prepend_before_action :project + before_action :repository - def index - clusters = ClustersFinder.new(project, current_user, :all).execute - @clusters = clusters.page(params[:page]).per(20) - end - - def new - end - - def status - respond_to do |format| - format.json do - Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL) - - render json: ClusterSerializer - .new(project: @project, current_user: @current_user) - .represent_status(@cluster) - end - end - end - - def show - end - - def update - Clusters::UpdateService - .new(current_user, update_params) - .execute(cluster) - - if cluster.valid? - respond_to do |format| - format.json do - head :no_content - end - format.html do - flash[:notice] = _('Kubernetes cluster was successfully updated.') - redirect_to project_cluster_path(project, cluster) - end - end - else - respond_to do |format| - format.json { head :bad_request } - format.html { render :show } - end - end - end - - def destroy - if cluster.destroy - flash[:notice] = _('Kubernetes cluster integration was successfully removed.') - redirect_to project_clusters_path(project), status: :found - else - flash[:notice] = _('Kubernetes cluster integration was not removed.') - render :show - end - end - - def create_gcp - @gcp_cluster = ::Clusters::CreateService - .new(current_user, create_gcp_cluster_params) - .execute(project: project, access_token: token_in_session) - - if @gcp_cluster.persisted? - redirect_to project_cluster_path(project, @gcp_cluster) - else - generate_gcp_authorize_url - validate_gcp_token - user_cluster - - render :new, locals: { active_tab: 'gcp' } - end - end - - def create_user - @user_cluster = ::Clusters::CreateService - .new(current_user, create_user_cluster_params) - .execute(project: project, access_token: token_in_session) - - if @user_cluster.persisted? - redirect_to project_cluster_path(project, @user_cluster) - else - generate_gcp_authorize_url - validate_gcp_token - gcp_cluster - - render :new, locals: { active_tab: 'user' } - end - end + layout 'project' private - def cluster - @cluster ||= project.clusters.find(params[:id]) - .present(current_user: current_user) - end - - def update_params - if cluster.managed? - params.require(:cluster).permit( - :enabled, - :environment_scope, - platform_kubernetes_attributes: [ - :namespace - ] - ) - else - params.require(:cluster).permit( - :enabled, - :name, - :environment_scope, - platform_kubernetes_attributes: [ - :api_url, - :token, - :ca_cert, - :namespace - ] - ) - end - end - - def create_gcp_cluster_params - params.require(:cluster).permit( - :enabled, - :name, - :environment_scope, - provider_gcp_attributes: [ - :gcp_project_id, - :zone, - :num_nodes, - :machine_type, - :legacy_abac - ]).merge( - provider_type: :gcp, - platform_type: :kubernetes - ) - end - - def create_user_cluster_params - params.require(:cluster).permit( - :enabled, - :name, - :environment_scope, - platform_kubernetes_attributes: [ - :namespace, - :api_url, - :token, - :ca_cert, - :authorization_type - ]).merge( - provider_type: :user, - platform_type: :kubernetes - ) - end - - def generate_gcp_authorize_url - state = generate_session_key_redirect(new_project_cluster_path(@project).to_s) - - @authorize_url = GoogleApi::CloudPlatform::Client.new( - nil, callback_google_api_auth_url, - state: state).authorize_url - rescue GoogleApi::Auth::ConfigMissingError - # no-op - end - - def gcp_cluster - @gcp_cluster = ::Clusters::Cluster.new.tap do |cluster| - cluster.build_provider_gcp - end - end - - def user_cluster - @user_cluster = ::Clusters::Cluster.new.tap do |cluster| - cluster.build_platform_kubernetes - end - end - - def validate_gcp_token - @valid_gcp_token = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - .validate_token(expires_at_in_session) - end - - def token_in_session - session[GoogleApi::CloudPlatform::Client.session_key_for_token] - end - - def expires_at_in_session - @expires_at_in_session ||= - session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] - end - - def generate_session_key_redirect(uri) - GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| - session[key] = uri - end - end - - def authorize_update_cluster! - access_denied! unless can?(current_user, :update_cluster, cluster) + def clusterable + @clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user) end - def authorize_admin_cluster! - access_denied! unless can?(current_user, :admin_cluster, cluster) + def project + @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc) end - def update_applications_status - @cluster.applications.each(&:schedule_status_update) + def repository + @repository ||= project.repository end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 00b63f55710..32fc5140366 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -43,7 +43,7 @@ class Projects::CommitController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def pipelines @pipelines = @commit.pipelines.order(id: :desc) - @pipelines = @pipelines.where(ref: params[:ref]) if params[:ref] + @pipelines = @pipelines.where(ref: params[:ref]).page(params[:page]).per(30) if params[:ref] respond_to do |format| format.html @@ -53,6 +53,7 @@ class Projects::CommitController < Projects::ApplicationController render json: { pipelines: PipelineSerializer .new(project: @project, current_user: @current_user) + .with_pagination(request, response) .represent(@pipelines), count: { all: @pipelines.count diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 27b83da4f54..4bdb857b2d9 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -84,13 +84,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def pipelines - @pipelines = @merge_request.all_pipelines + @pipelines = @merge_request.all_pipelines.page(params[:page]).per(30) Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: { pipelines: PipelineSerializer .new(project: @project, current_user: @current_user) + .with_pagination(request, response) .represent(@pipelines), count: { all: @pipelines.count diff --git a/app/finders/clusters_finder.rb b/app/finders/clusters_finder.rb index b40d6c41b71..0cce493b73e 100644 --- a/app/finders/clusters_finder.rb +++ b/app/finders/clusters_finder.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true class ClustersFinder - def initialize(project, user, scope) - @project = project + def initialize(clusterable, user, scope) + @clusterable = clusterable @user = user @scope = scope || :active end def execute - clusters = project.clusters + clusters = clusterable.clusters filter_by_scope(clusters) end private - attr_reader :project, :user, :scope + attr_reader :clusterable, :user, :scope def filter_by_scope(clusters) case scope.to_sym diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index 81fd3b7a547..bd95dcd323f 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -3,7 +3,7 @@ class PersonalAccessTokensFinder attr_accessor :params - delegate :build, :find, :find_by, :find_by_token, to: :execute + delegate :build, :find, :find_by_id, :find_by_token, to: :execute def initialize(params = {}) @params = params diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 0c9f69b6714..9a1c2a4c9e1 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -115,6 +115,7 @@ module ApplicationSettingsHelper :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, + :archive_builds_in_human_readable, :authorized_keys_enabled, :auto_devops_enabled, :auto_devops_domain, diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 19eb763e1de..916dcb1a308 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module ClustersHelper - def has_multiple_clusters?(project) + # EE overrides this + def has_multiple_clusters? false end @@ -10,7 +11,7 @@ module ClustersHelper return unless show_gcp_signup_offer? content_tag :section, class: 'no-animate expanded' do - render 'projects/clusters/gcp_signup_offer_banner' + render 'clusters/clusters/gcp_signup_offer_banner' end end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index c94946a04e7..2adfc04deb8 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -163,14 +163,10 @@ module EventsHelper def event_note_title_html(event) if event.note_target - text = raw("#{event.note_target_type} ") + - if event.commit_note? - content_tag(:span, event.note_target_reference, class: 'commit-sha') - else - event.note_target_reference - end - - link_to(text, event_note_target_url(event), title: event.target_title, class: 'has-tooltip') + capture do + concat content_tag(:span, event.note_target_type, class: "event-target-type append-right-4") + concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link append-right-4') + end else content_tag(:strong, '(deleted)') end @@ -183,17 +179,9 @@ module EventsHelper "--broken encoding" end - def event_row_class(event) - if event.body? - "event-block" - else - "event-inline" - end - end - - def icon_for_event(note) + def icon_for_event(note, size: 24) icon_name = ICON_NAMES_BY_EVENT_TYPE[note] - sprite_icon(icon_name) if icon_name + sprite_icon(icon_name, size: size) if icon_name end def icon_for_profile_event(event) @@ -203,8 +191,24 @@ module EventsHelper end else content_tag :div, class: 'system-note-image user-avatar' do - author_avatar(event, size: 32) + author_avatar(event, size: 40) + end + end + end + + def inline_event_icon(event) + unless current_path?('users#show') + content_tag :span, class: "system-note-image-inline d-none d-sm-flex append-right-4 #{event.action_name.parameterize}-icon align-self-center" do + icon_for_event(event.action_name, size: 14) end end end + + def event_user_info(event) + content_tag(:div, class: "event-user-info") do + concat content_tag(:span, link_to_author(event), class: "author_name") + concat " ".html_safe + concat content_tag(:span, event.author.to_reference, class: "username") + end + end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 910c9e9446f..b0f63de2fb8 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -109,6 +109,8 @@ module IconsHelper def file_type_icon_class(type, mode, name) if type == 'folder' icon_class = 'folder' + elsif type == 'archive' + icon_class = 'archive' elsif mode == '120000' icon_class = 'share' else diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 76ed8efe2c6..39f661b5f0c 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -143,7 +143,7 @@ module LabelsHelper def labels_filter_path(options = {}) project = @target_project || @project - format = options.delete(:format) || :html + format = options.delete(:format) if project project_labels_path(project, format, options) diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 6d2da5699fb..78a11616d4c 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -31,11 +31,21 @@ module TreeHelper # mode - File unix mode # name - File name def tree_icon(type, mode, name) - icon("#{file_type_icon_class(type, mode, name)} fw") + icon([file_type_icon_class(type, mode, name), 'fw']) end - def tree_hex_class(content) - "file_#{hexdigest(content.name)}" + # Using Rails `*_path` methods can be slow, especially when generating + # many paths, as with a repository tree that has thousands of items. + def fast_project_blob_path(project, blob_path) + Addressable::URI.escape( + File.join(relative_url_root, project.path_with_namespace, 'blob', blob_path) + ) + end + + def fast_project_tree_path(project, tree_path) + Addressable::URI.escape( + File.join(relative_url_root, project.path_with_namespace, 'tree', tree_path) + ) end # Simple shortcut to File.join @@ -142,4 +152,8 @@ module TreeHelper def selected_branch @branch_name || tree_edit_branch end + + def relative_url_root + Gitlab.config.gitlab.relative_url_root.presence || '/' + end end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index bae01d476df..4aba48061ba 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -3,7 +3,6 @@ module UserCalloutsHelper GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze GCP_SIGNUP_OFFER = 'gcp_signup_offer'.freeze - CLUSTER_SECURITY_WARNING = 'cluster_security_warning'.freeze def show_gke_cluster_integration_callout?(project) can?(current_user, :create_cluster, project) && @@ -14,10 +13,6 @@ module UserCalloutsHelper !user_dismissed?(GCP_SIGNUP_OFFER) end - def show_cluster_security_warning? - !user_dismissed?(CLUSTER_SECURITY_WARNING) - end - private def user_dismissed?(feature_name) diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 602e5afe26b..93b51fb1774 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -45,6 +45,20 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) end + def removed_milestone_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil) + setup_issue_mail(issue_id, recipient_id) + + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + end + + def changed_milestone_issue_email(recipient_id, issue_id, milestone, updated_by_user_id, reason = nil) + setup_issue_mail(issue_id, recipient_id) + + @milestone = milestone + @milestone_url = milestone_url(@milestone) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + end + def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil) setup_issue_mail(issue_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index be085496731..6524d0c2087 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -40,6 +40,20 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end + def removed_milestone_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + end + + def changed_milestone_merge_request_email(recipient_id, merge_request_id, milestone, updated_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + @milestone = milestone + @milestone_url = milestone_url(@milestone) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + end + def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 2f5b5483e9d..e7e8d96eca4 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -68,6 +68,14 @@ class NotifyPreview < ActionMailer::Preview Notify.issue_status_changed_email(user.id, issue.id, 'closed', user.id).message end + def removed_milestone_issue_email + Notify.removed_milestone_issue_email(user.id, issue.id, user.id) + end + + def changed_milestone_issue_email + Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id) + end + def closed_merge_request_email Notify.closed_merge_request_email(user.id, issue.id, user.id).message end @@ -80,6 +88,14 @@ class NotifyPreview < ActionMailer::Preview Notify.merged_merge_request_email(user.id, merge_request.id, user.id).message end + def removed_milestone_merge_request_email + Notify.removed_milestone_merge_request_email(user.id, merge_request.id, user.id) + end + + def changed_milestone_merge_request_email + Notify.changed_milestone_merge_request_email(user.id, merge_request.id, milestone, user.id) + end + def member_access_denied_email Notify.member_access_denied_email('project', project.id, user.id).message end @@ -143,6 +159,10 @@ class NotifyPreview < ActionMailer::Preview @merge_request ||= project.merge_requests.first end + def milestone + @milestone ||= issue.milestone + end + def pipeline @pipeline = Ci::Pipeline.last end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index b66ec0ffab6..704310f53f0 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -5,6 +5,7 @@ class ApplicationSetting < ActiveRecord::Base include CacheMarkdownField include TokenAuthenticatable include IgnorableColumn + include ChronicDurationAttribute add_authentication_token_field :runners_registration_token add_authentication_token_field :health_check_access_token @@ -45,6 +46,8 @@ class ApplicationSetting < ActiveRecord::Base default_value_for :id, 1 + chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds + validates :uuid, presence: true validates :session_expire_delay, @@ -184,6 +187,10 @@ class ApplicationSetting < ActiveRecord::Base validates :user_default_internal_regex, js_regex: true, allow_nil: true + validates :archive_builds_in_seconds, + allow_nil: true, + numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -441,6 +448,10 @@ class ApplicationSetting < ActiveRecord::Base latest_terms end + def archive_builds_older_than + archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds + end + private def ensure_uuid! diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d73c02ba5d7..360c9924a7d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -9,19 +9,18 @@ module Ci include Presentable include Importable include Gitlab::Utils::StrongMemoize + include Deployable belongs_to :project, inverse_of: :builds belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' - has_many :deployments, as: :deployable - RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? } }.freeze - has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' + has_one :deployment, as: :deployable, class_name: 'Deployment' has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id @@ -195,6 +194,8 @@ module Ci end after_transition pending: :running do |build| + build.deployment&.run + build.run_after_commit do BuildHooksWorker.perform_async(id) end @@ -207,14 +208,18 @@ module Ci end after_transition any => [:success] do |build| + build.deployment&.succeed + build.run_after_commit do - BuildSuccessWorker.perform_async(id) PagesWorker.perform_async(:deploy, id) if build.pages_generator? end end before_transition any => [:failed] do |build| next unless build.project + + build.deployment&.drop + next if build.retries_max.zero? if build.retries_count < build.retries_max @@ -233,6 +238,10 @@ module Ci after_transition running: any do |build| Ci::BuildRunnerSession.where(build: build).delete_all end + + after_transition any => [:skipped, :canceled] do |build| + build.deployment&.cancel + end end def ensure_metadata @@ -245,22 +254,41 @@ module Ci .fabricate! end - def other_actions + def other_manual_actions pipeline.manual_actions.where.not(name: name) end + def other_scheduled_actions + pipeline.scheduled_actions.where.not(name: name) + end + def pages_generator? Gitlab.config.pages.enabled && self.name == 'pages' end + # degenerated build is one that cannot be run by Runner + def degenerated? + self.options.nil? + end + + def degenerate! + self.update!(options: nil, yaml_variables: nil, commands: nil) + end + + def archived? + return true if degenerated? + + archive_builds_older_than = Gitlab::CurrentSettings.current_application_settings.archive_builds_older_than + archive_builds_older_than.present? && created_at < archive_builds_older_than + end + def playable? - action? && (manual? || scheduled? || retryable?) + action? && !archived? && (manual? || scheduled? || retryable?) end def schedulable? - Feature.enabled?('ci_enable_scheduled_build', default_enabled: true) && - self.when == 'delayed' && options[:start_in].present? + self.when == 'delayed' && options[:start_in].present? end def options_scheduled_at @@ -284,7 +312,7 @@ module Ci end def retryable? - success? || failed? || canceled? + !archived? && (success? || failed? || canceled?) end def retries_count @@ -292,7 +320,7 @@ module Ci end def retries_max - self.options.fetch(:retry, 0).to_i + self.options.to_h.fetch(:retry, 0).to_i end def latest? @@ -323,8 +351,12 @@ module Ci self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options end + def has_deployment? + !!self.deployment + end + def outdated_deployment? - success? && !last_deployment.try(:last?) + success? && !deployment.try(:last?) end def depends_on_builds @@ -339,6 +371,10 @@ module Ci user == current_user end + def on_stop + options&.dig(:environment, :on_stop) + end + # A slugified version of the build ref, suitable for inclusion in URLs and # domain names. Rules: # @@ -706,7 +742,7 @@ module Ci if success? return successful_deployment_status - elsif complete? && !success? + elsif failed? return :failed end @@ -723,13 +759,11 @@ module Ci end def successful_deployment_status - if success? && last_deployment&.last? - return :last - elsif success? && last_deployment.present? - return :out_of_date + if deployment&.last? + :last + else + :out_of_date end - - :creating end def each_report(report_types) diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 34a889057ab..11c88200c37 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -15,7 +15,7 @@ module Ci metadata: nil, trace: nil, junit: 'junit.xml', - codequality: 'codequality.json', + codequality: 'gl-code-quality-report.json', sast: 'gl-sast-report.json', dependency_scanning: 'gl-dependency-scanning-report.json', container_scanning: 'gl-container-scanning-report.json', diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index aeee7f0a5d2..56010e899a4 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -181,22 +181,31 @@ module Ci # # ref - The name (or names) of the branch(es)/tag(s) to limit the list of # pipelines to. - def self.newest_first(ref = nil) + # limit - This limits a backlog search, default to 100. + def self.newest_first(ref: nil, limit: 100) relation = order(id: :desc) + relation = relation.where(ref: ref) if ref + + if limit + ids = relation.limit(limit).select(:id) + # MySQL does not support limit in subquery + ids = ids.pluck(:id) if Gitlab::Database.mysql? + relation = relation.where(id: ids) + end - ref ? relation.where(ref: ref) : relation + relation end def self.latest_status(ref = nil) - newest_first(ref).pluck(:status).first + newest_first(ref: ref).pluck(:status).first end def self.latest_successful_for(ref) - newest_first(ref).success.take + newest_first(ref: ref).success.take end def self.latest_successful_for_refs(refs) - relation = newest_first(refs).success + relation = newest_first(ref: refs).success relation.each_with_object({}) do |pipeline, hash| hash[pipeline.ref] ||= pipeline @@ -238,6 +247,10 @@ module Ci end end + def self.latest_successful_ids_per_project + success.group(:project_id).select('max(id) as id') + end + def self.truncate_sha(sha) sha[0...8] end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 222e4217e67..e80d35d0f3c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -3,6 +3,7 @@ module Clusters class Cluster < ActiveRecord::Base include Presentable + include Gitlab::Utils::StrongMemoize self.table_name = 'clusters' @@ -19,13 +20,11 @@ module Clusters has_many :cluster_projects, class_name: 'Clusters::Project' has_many :projects, through: :cluster_projects, class_name: '::Project' + has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project' has_many :cluster_groups, class_name: 'Clusters::Group' has_many :groups, through: :cluster_groups, class_name: '::Group' - has_one :cluster_group, -> { order(id: :desc) }, class_name: 'Clusters::Group' - has_one :group, through: :cluster_group, class_name: '::Group' - # we force autosave to happen when we save `Cluster` model has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true @@ -118,16 +117,30 @@ module Clusters end def first_project - return @first_project if defined?(@first_project) - - @first_project = projects.first + strong_memoize(:first_project) do + projects.first + end end alias_method :project, :first_project + def first_group + strong_memoize(:first_group) do + groups.first + end + end + alias_method :group, :first_group + def kubeclient 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 + ) + end + private def restrict_modification diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb index fb5f6b65d9d..ac7f9193b87 100644 --- a/app/models/clusters/kubernetes_namespace.rb +++ b/app/models/clusters/kubernetes_namespace.rb @@ -2,6 +2,8 @@ module Clusters class KubernetesNamespace < ActiveRecord::Base + include Gitlab::Kubernetes + self.table_name = 'clusters_kubernetes_namespaces' belongs_to :cluster_project, class_name: 'Clusters::Project' @@ -12,7 +14,8 @@ module Clusters validates :namespace, presence: true validates :namespace, uniqueness: { scope: :cluster_id } - before_validation :set_namespace_and_service_account_to_default, on: :create + delegate :ca_pem, to: :platform_kubernetes, allow_nil: true + delegate :api_url, to: :platform_kubernetes, allow_nil: true attr_encrypted :service_account_token, mode: :per_attribute_iv, @@ -23,14 +26,26 @@ module Clusters "#{namespace}-token" end - private + def configure_predefined_credentials + self.namespace = kubernetes_or_project_namespace + self.service_account_name = default_service_account_name + end + + def predefined_variables + config = YAML.dump(kubeconfig) - def set_namespace_and_service_account_to_default - self.namespace ||= default_namespace - self.service_account_name ||= default_service_account_name + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name) + .append(key: 'KUBE_NAMESPACE', value: namespace) + .append(key: 'KUBE_TOKEN', value: service_account_token, public: false) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + end end - def default_namespace + private + + def kubernetes_or_project_namespace platform_kubernetes&.namespace.presence || project_namespace end @@ -45,5 +60,13 @@ module Clusters def project_slug "#{project.path}-#{project.id}".downcase end + + def kubeconfig + to_kubeconfig( + url: api_url, + namespace: namespace, + token: service_account_token, + ca_pem: ca_pem) + end end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index f0f791742f4..008e08d9914 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -6,6 +6,7 @@ module Clusters include Gitlab::Kubernetes include ReactiveCaching include EnumWithNil + include AfterCommitQueue RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze @@ -43,6 +44,7 @@ module Clusters validate :prevent_modification, on: :update after_save :clear_reactive_cache! + after_update :update_kubernetes_namespace alias_attribute :ca_pem, :ca_cert @@ -67,21 +69,31 @@ module Clusters end end - def predefined_variables - config = YAML.dump(kubeconfig) - + def predefined_variables(project:) 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) + variables.append(key: 'KUBE_URL', value: api_url) if ca_pem.present? variables .append(key: 'KUBE_CA_PEM', value: ca_pem) .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) end + + if kubernetes_namespace = cluster.kubernetes_namespaces.find_by(project: project) + variables.concat(kubernetes_namespace.predefined_variables) + else + # 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) + end end end @@ -199,6 +211,14 @@ module Clusters true end + + def update_kubernetes_namespace + return unless namespace_changed? + + run_after_commit do + ClusterPlatformConfigureWorker.perform_async(cluster_id) + end + end end end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 95c88e11a6e..755f8bd4d06 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -51,7 +51,8 @@ class CommitStatus < ActiveRecord::Base missing_dependency_failure: 5, runner_unsupported: 6, stale_schedule: 7, - job_execution_timeout: 8 + job_execution_timeout: 8, + archived_failure: 9 } ## @@ -167,16 +168,18 @@ class CommitStatus < ActiveRecord::Base false end - # To be overridden when inherrited from def retryable? false end - # To be overridden when inherrited from def cancelable? false end + def archived? + false + end + def stuck? false end diff --git a/app/models/concerns/deployable.rb b/app/models/concerns/deployable.rb new file mode 100644 index 00000000000..f4f1989f0a9 --- /dev/null +++ b/app/models/concerns/deployable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Deployable + extend ActiveSupport::Concern + + included do + after_create :create_deployment + + def create_deployment + return unless starts_environment? && !has_deployment? + + environment = project.environments.find_or_create_by( + name: expanded_environment_name + ) + + environment.deployments.create!( + project_id: environment.project_id, + environment: environment, + ref: ref, + tag: tag, + sha: sha, + user: user, + deployable: self, + on_stop: on_stop).tap do |_| + self.reload # Reload relationships + end + end + end +end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 66db4bd92de..23a43aec677 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -10,6 +10,7 @@ module TokenAuthenticatable def add_authentication_token_field(token_field, options = {}) @token_fields = [] unless @token_fields + unique = options.fetch(:unique, true) if @token_fields.include?(token_field) raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field") @@ -25,8 +26,10 @@ module TokenAuthenticatable TokenAuthenticatableStrategies::Insecure.new(self, token_field, options) end - define_singleton_method("find_by_#{token_field}") do |token| - strategy.find_token_authenticatable(token) + if unique + define_singleton_method("find_by_#{token_field}") do |token| + strategy.find_token_authenticatable(token) + end end define_method(token_field) do diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index f0f7107d627..413721d3e6c 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -43,10 +43,14 @@ module TokenAuthenticatableStrategies set_token(instance, new_token) end + def unique + @options.fetch(:unique, true) + end + def generate_available_token loop do token = generate_token - break token unless find_token_authenticatable(token, true) + break token unless unique && find_token_authenticatable(token, true) end end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 0b2eedf3631..e3524305346 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -4,6 +4,7 @@ class DeployToken < ActiveRecord::Base include Expirable include TokenAuthenticatable include PolicyActor + include Gitlab::Utils::StrongMemoize add_authentication_token_field :token AVAILABLE_SCOPES = %i(read_repository read_registry).freeze @@ -49,7 +50,9 @@ class DeployToken < ActiveRecord::Base # to a single project, later we're going to extend # that to be for multiple projects and namespaces. def project - projects.first + strong_memoize(:project) do + projects.first + end end def expires_at diff --git a/app/models/deployment.rb b/app/models/deployment.rb index ee5b96e7454..54a900a3b85 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -3,6 +3,7 @@ class Deployment < ActiveRecord::Base include AtomicInternalId include IidRoutes + include AfterCommitQueue belongs_to :project, required: true belongs_to :environment, required: true @@ -16,11 +17,44 @@ class Deployment < ActiveRecord::Base delegate :name, to: :environment, prefix: true - after_create :create_ref - after_create :invalidate_cache - scope :for_environment, -> (environment) { where(environment_id: environment) } + state_machine :status, initial: :created do + event :run do + transition created: :running + end + + event :succeed do + transition any - [:success] => :success + end + + event :drop do + transition any - [:failed] => :failed + end + + event :cancel do + transition any - [:canceled] => :canceled + end + + before_transition any => [:success, :failed, :canceled] do |deployment| + deployment.finished_at = Time.now + end + + after_transition any => :success do |deployment| + deployment.run_after_commit do + Deployments::SuccessWorker.perform_async(id) + end + end + end + + enum status: { + created: 0, + running: 1, + success: 2, + failed: 3, + canceled: 4 + } + def self.last_for_environment(environment) ids = self .for_environment(environment) @@ -55,7 +89,11 @@ class Deployment < ActiveRecord::Base end def manual_actions - @manual_actions ||= deployable.try(:other_actions) + @manual_actions ||= deployable.try(:other_manual_actions) + end + + def scheduled_actions + @scheduled_actions ||= deployable.try(:other_scheduled_actions) end def includes_commit?(commit) @@ -65,15 +103,15 @@ class Deployment < ActiveRecord::Base end def update_merge_request_metrics! - return unless environment.update_merge_request_metrics? + return unless environment.update_merge_request_metrics? && success? merge_requests = project.merge_requests .joins(:metrics) .where(target_branch: self.ref, merge_request_metrics: { first_deployed_to_production_at: nil }) - .where("merge_request_metrics.merged_at <= ?", self.created_at) + .where("merge_request_metrics.merged_at <= ?", finished_at) if previous_deployment - merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.created_at) + merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.finished_at) end # Need to use `map` instead of `select` because MySQL doesn't allow `SELECT`ing from the same table @@ -87,7 +125,7 @@ class Deployment < ActiveRecord::Base MergeRequest::Metrics .where(merge_request_id: merge_request_ids, first_deployed_to_production_at: nil) - .update_all(first_deployed_to_production_at: self.created_at) + .update_all(first_deployed_to_production_at: finished_at) end def previous_deployment @@ -105,8 +143,18 @@ class Deployment < ActiveRecord::Base @stop_action ||= manual_actions.find_by(name: on_stop) end + def finished_at + read_attribute(:finished_at) || legacy_finished_at + end + + def deployed_at + return unless success? + + finished_at + end + def formatted_deployment_time - created_at.to_time.in_time_zone.to_s(:medium) + deployed_at&.to_time&.in_time_zone&.to_s(:medium) end def has_metrics? @@ -114,21 +162,17 @@ class Deployment < ActiveRecord::Base end def metrics - return {} unless has_metrics? + return {} unless has_metrics? && success? metrics = prometheus_adapter.query(:deployment, self) - metrics&.merge(deployment_time: created_at.to_i) || {} + metrics&.merge(deployment_time: finished_at.to_i) || {} end def additional_metrics - return {} unless has_metrics? + return {} unless has_metrics? && success? metrics = prometheus_adapter.query(:additional_metrics_deployment, self) - metrics&.merge(deployment_time: created_at.to_i) || {} - end - - def status - 'success' + metrics&.merge(deployment_time: finished_at.to_i) || {} end private @@ -140,4 +184,8 @@ class Deployment < ActiveRecord::Base def ref_path File.join(environment.ref_path, 'deployments', iid.to_s) end + + def legacy_finished_at + self.created_at if success? && !read_attribute(:finished_at) + end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 1c31c01eb9f..7d104bb0c25 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -8,9 +8,9 @@ class Environment < ActiveRecord::Base belongs_to :project, required: true - has_many :deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :deployments, -> { success }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_one :last_deployment, -> { order('deployments.id DESC') }, class_name: 'Deployment' + has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index a84871f7253..7efc8da09ad 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -8,8 +8,8 @@ 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 - delegate :status, to: :deployment def self.for_merge_request(mr, user) build_environments_status(mr, user, mr.head_pipeline) @@ -33,10 +33,6 @@ class EnvironmentStatus end end - def deployed_at - deployment&.created_at - end - def changes return [] if project.route_map_for(sha).nil? diff --git a/app/models/issue.rb b/app/models/issue.rb index 4ace5d3ab97..0de5e434b02 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -240,7 +240,8 @@ class Issue < ActiveRecord::Base 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) + 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 diff --git a/app/models/key.rb b/app/models/key.rb index bdb83e12793..8f93418b88b 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -34,6 +34,10 @@ class Key < ActiveRecord::Base after_destroy :post_destroy_hook after_destroy :refresh_user_cache + def self.regular_keys + where(type: ['Key', nil]) + end + def key=(value) write_attribute(:key, value.present? ? Gitlab::SSHPublicKey.sanitize(value) : nil) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7eef08aa6a3..735d9fba966 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -353,6 +353,15 @@ class MergeRequest < ActiveRecord::Base end end + # Returns true if there are commits that match at least one commit SHA. + def includes_any_commits?(shas) + if persisted? + merge_request_diff.commits_by_shas(shas).exists? + else + (commit_shas & shas).present? + end + end + # Calls `MergeWorker` to proceed with the merge process and # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 02c6b650f33..bb6ff8921df 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -140,6 +140,12 @@ class MergeRequestDiff < ActiveRecord::Base merge_request_diff_commits.map(&:sha) end + def commits_by_shas(shas) + return [] unless shas.present? + + merge_request_diff_commits.where(sha: shas) + end + def diff_refs=(new_diff_refs) self.base_commit_sha = new_diff_refs&.base_sha self.start_commit_sha = new_diff_refs&.start_sha diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 74d48d0a9af..4a6627d3ca1 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -232,6 +232,12 @@ class Namespace < ActiveRecord::Base Project.inside_path(full_path) end + # Includes pipelines from this namespace and pipelines from all subgroups + # that belongs to this namespace + def all_pipelines + Ci::Pipeline.where(project: all_projects) + end + def has_parent? parent.present? end diff --git a/app/models/note.rb b/app/models/note.rb index 990689a95f5..592efb714f3 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -117,6 +117,8 @@ class Note < ActiveRecord::Base case notes_filter when UserPreference::NOTES_FILTERS[:only_comments] user + when UserPreference::NOTES_FILTERS[:only_activity] + system else all end diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb new file mode 100644 index 00000000000..8ef74539209 --- /dev/null +++ b/app/models/pool_repository.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PoolRepository < ActiveRecord::Base + POOL_PREFIX = '@pools' + + belongs_to :shard + validates :shard, presence: true + + # 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' + + has_many :pool_member_projects, class_name: 'Project', foreign_key: :pool_repository_id + + def shard_name + shard&.name + end + + def shard_name=(name) + self.shard = Shard.by_name(name) + end +end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index 70c7432e6b5..e264fe88e47 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -4,6 +4,15 @@ module Postgresql class ReplicationSlot < ActiveRecord::Base self.table_name = 'pg_replication_slots' + # Returns true if there are any replication slots in use. + # PostgreSQL-compatible databases such as Aurora don't support + # replication slots, so this will return false as well. + def self.in_use? + transaction { exists? } + rescue ActiveRecord::StatementInvalid + false + end + # Returns true if the lag observed across all replication slots exceeds a # given threshold. # @@ -11,6 +20,8 @@ module Postgresql # statistics it takes between 1 and 5 seconds to replicate around # 100 MB of data. def self.lag_too_great?(max = 100.megabytes) + return false unless in_use? + lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \ "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint" diff --git a/app/models/project.rb b/app/models/project.rb index 4273863d529..48905547ab4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -95,8 +95,7 @@ class Project < ActiveRecord::Base unless: :ci_cd_settings, if: proc { ProjectCiCdSetting.available? } - after_create :set_last_activity_at - after_create :set_last_repository_updated_at + after_create :set_timestamps_for_create after_update :update_forks_visibility_level before_destroy :remove_private_deploy_keys @@ -124,6 +123,7 @@ class Project < ActiveRecord::Base alias_attribute :title, :name # Relations + belongs_to :pool_repository belongs_to :creator, class_name: 'User' belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' belongs_to :namespace @@ -254,7 +254,7 @@ class Project < ActiveRecord::Base has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments - has_many :deployments + has_many :deployments, -> { success } has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens @@ -1829,7 +1829,7 @@ class Project < ActiveRecord::Base end def deployment_variables(environment: nil) - deployment_platform(environment: environment)&.predefined_variables || [] + deployment_platform(environment: environment)&.predefined_variables(project: self) || [] end def auto_devops_variables @@ -2106,13 +2106,8 @@ class Project < ActiveRecord::Base gitlab_shell.exists?(repository_storage, "#{disk_path}.git") end - # set last_activity_at to the same as created_at - def set_last_activity_at - update_column(:last_activity_at, self.created_at) - end - - def set_last_repository_updated_at - update_column(:last_repository_updated_at, self.created_at) + def set_timestamps_for_create + update_columns(last_activity_at: self.created_at, last_repository_updated_at: self.created_at) end def cross_namespace_reference?(from) diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 798944d0c06..3459ded7ccf 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -104,7 +104,12 @@ class KubernetesService < DeploymentService { success: false, result: err } end - def predefined_variables + # Project param was added on + # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22011, + # as a way to keep this service compatible with + # 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| diff --git a/app/models/shard.rb b/app/models/shard.rb new file mode 100644 index 00000000000..2fa22bd040c --- /dev/null +++ b/app/models/shard.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Shard < ActiveRecord::Base + # Store shard names from the configuration file in the database. This is not a + # list of active shards - we just want to assign an immutable, unique ID to + # every shard name for easy indexing / referencing. + def self.populate! + return unless table_exists? + + # The GitLab config does not change for the lifecycle of the process + in_config = Gitlab.config.repositories.storages.keys.map(&:to_s) + + transaction do + in_db = all.pluck(:name) + missing = in_config - in_db + + missing.map { |name| by_name(name) } + end + end + + def self.by_name(name) + find_or_create_by(name: name) + rescue ActiveRecord::RecordNotUnique + retry + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cc2cd1b7723..039a3854edb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -88,7 +88,7 @@ class User < ActiveRecord::Base has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent # Profile - has_many :keys, -> { where(type: ['Key', nil]) }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :gpg_keys @@ -460,12 +460,6 @@ class User < ActiveRecord::Base by_username(username).take! end - def find_by_personal_access_token(token_string) - return unless token_string - - PersonalAccessTokensFinder.new(state: 'active').find_by_token(token_string)&.user # rubocop: disable CodeReuse/Finder - end - # Returns a user for the given SSH key. def find_by_ssh_key_id(key_id) Key.find_by(id: key_id)&.user @@ -941,12 +935,17 @@ class User < ActiveRecord::Base if !Gitlab.config.ldap.enabled false elsif ldap_user? - !last_credential_check_at || (last_credential_check_at + 1.hour) < Time.now + !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.now else false end end + def ldap_sync_time + # This number resides in this method so it can be redefined in EE. + 1.hour + end + def try_obtain_ldap_lease # After obtaining this lease LDAP checks will be blocked for 600 seconds # (10 minutes) for this user. diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 6cd91abc261..32d0407800f 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -4,7 +4,7 @@ class UserPreference < ActiveRecord::Base # We could use enums, but Rails 4 doesn't support multiple # enum options with same name for multiple fields, also it creates # extra methods that aren't really needed here. - NOTES_FILTERS = { all_notes: 0, only_comments: 1 }.freeze + NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze belongs_to :user @@ -14,7 +14,8 @@ class UserPreference < ActiveRecord::Base def notes_filters { s_('Notes|Show all activity') => NOTES_FILTERS[:all_notes], - s_('Notes|Show comments only') => NOTES_FILTERS[:only_comments] + s_('Notes|Show comments only') => NOTES_FILTERS[:only_comments], + s_('Notes|Show history only') => NOTES_FILTERS[:only_activity] } end end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 3858b29c82c..0ca3e696f46 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -20,12 +20,17 @@ module Ci @subject.project.branch_allows_collaboration?(@user, @subject.ref) end + condition(:archived, scope: :subject) do + @subject.archived? + end + condition(:terminal, scope: :subject) do @subject.has_terminal? end - rule { protected_ref }.policy do + rule { protected_ref | archived }.policy do prevent :update_build + prevent :update_commit_status prevent :erase_build end diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb index 56ac898b6ab..d4f2f3c52b1 100644 --- a/app/policies/deployment_policy.rb +++ b/app/policies/deployment_policy.rb @@ -2,4 +2,13 @@ class DeploymentPolicy < BasePolicy delegate { @subject.project } + + condition(:can_retry_deployable) do + can?(:update_build, @subject.deployable) + end + + rule { ~can_retry_deployable }.policy do + prevent :create_deployment + prevent :update_deployment + end end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb new file mode 100644 index 00000000000..cff0e74d6ea --- /dev/null +++ b/app/presenters/clusterable_presenter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class ClusterablePresenter < Gitlab::View::Presenter::Delegated + presents :clusterable + + def self.fabricate(clusterable, **attributes) + presenter_class = "#{clusterable.class.name}ClusterablePresenter".constantize + attributes_with_presenter_class = attributes.merge(presenter_class: presenter_class) + + Gitlab::View::Presenter::Factory + .new(clusterable, attributes_with_presenter_class) + .fabricate! + end + + def can_create_cluster? + can?(current_user, :create_cluster, clusterable) + end + + def index_path + polymorphic_path([clusterable, :clusters]) + end + + def new_path + new_polymorphic_path([clusterable, :cluster]) + end + + def create_user_clusters_path + polymorphic_path([clusterable, :clusters], action: :create_user) + end + + def create_gcp_clusters_path + polymorphic_path([clusterable, :clusters], action: :create_gcp) + end + + def cluster_status_cluster_path(cluster, params = {}) + raise NotImplementedError + end + + def install_applications_cluster_path(cluster, application) + raise NotImplementedError + end + + def cluster_path(cluster, params = {}) + raise NotImplementedError + end +end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index dfdd8e82f97..78d632eb77c 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -11,5 +11,13 @@ module Clusters def can_toggle_cluster? can?(current_user, :update_cluster, cluster) && created? end + + def show_path + if cluster.project_type? + project_cluster_path(project, cluster) + else + raise NotImplementedError + end + end end end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index a866e76df5a..0cd77da6303 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -10,7 +10,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated missing_dependency_failure: 'There has been a missing dependency failure', runner_unsupported: 'Your runner is outdated, please upgrade your runner', stale_schedule: 'Delayed job could not be executed by some reason, please try again', - job_execution_timeout: 'The script exceeded the maximum execution time set for the job' + job_execution_timeout: 'The script exceeded the maximum execution time set for the job', + archived_failure: 'The job is archived and cannot be run' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES @@ -30,6 +31,6 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated end def unrecoverable? - script_failure? || missing_dependency_failure? + script_failure? || missing_dependency_failure? || archived_failure? end end diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb new file mode 100644 index 00000000000..12077b2e735 --- /dev/null +++ b/app/presenters/project_clusterable_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ProjectClusterablePresenter < ClusterablePresenter + def cluster_status_cluster_path(cluster, params = {}) + cluster_status_project_cluster_path(clusterable, cluster, params) + end + + def install_applications_cluster_path(cluster, application) + install_applications_project_cluster_path(clusterable, cluster, application) + end + + def cluster_path(cluster, params = {}) + project_cluster_path(clusterable, cluster, params) + end +end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 0db7875aa87..95833c3528f 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -12,7 +12,8 @@ class BuildActionEntity < Grape::Entity end expose :playable?, as: :playable - expose :scheduled_at, if: -> (build) { build.scheduled? } + expose :scheduled?, as: :scheduled + expose :scheduled_at, if: -> (*) { scheduled? } expose :unschedule_path, if: -> (build) { build.scheduled? } do |build| unschedule_project_job_path(build.project, build) @@ -25,4 +26,8 @@ class BuildActionEntity < Grape::Entity def playable? build.playable? && can?(request.current_user, :update_build, build) end + + def scheduled? + build.scheduled? + end end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 344148a1fb7..aa1d9e6292c 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -25,4 +25,5 @@ class DeploymentEntity < Grape::Entity expose :commit, using: CommitEntity expose :deployable, using: JobEntity expose :manual_actions, using: JobEntity + expose :scheduled_actions, using: JobEntity end diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb index a0a66511b7b..d0099ae77f2 100644 --- a/app/serializers/job_entity.rb +++ b/app/serializers/job_entity.rb @@ -7,6 +7,7 @@ class JobEntity < Grape::Entity expose :name expose :started?, as: :started + expose :archived?, as: :archived expose :build_path do |build| build_path(build) @@ -33,6 +34,7 @@ class JobEntity < Grape::Entity end expose :playable?, as: :playable + expose :scheduled?, as: :scheduled expose :scheduled_at, if: -> (*) { scheduled? } expose :created_at expose :updated_at diff --git a/app/serializers/user_preference_entity.rb b/app/serializers/user_preference_entity.rb index fbdaab459b3..b99f80424db 100644 --- a/app/serializers/user_preference_entity.rb +++ b/app/serializers/user_preference_entity.rb @@ -7,4 +7,8 @@ class UserPreferenceEntity < Grape::Entity expose :notes_filters do |user_preference| UserPreference.notes_filters end + + expose :default_notes_filter do |user_preference| + UserPreference::NOTES_FILTERS[:all_notes] + end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 5a7be921389..e06f1c05843 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -82,6 +82,11 @@ module Ci return false end + if build.archived? + build.drop!(:archived_failure) + return false + end + build.run! true end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index cd843b8ffa8..270db4a52fd 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -8,10 +8,11 @@ module Clusters @current_user, @params = user, params.dup end - def execute(project:, access_token: nil) - raise ArgumentError, _('Instance does not support multiple Kubernetes clusters') unless can_create_cluster?(project) + def execute(access_token: nil) + raise ArgumentError, 'Unknown clusterable provided' unless clusterable + raise ArgumentError, _('Instance does not support multiple Kubernetes clusters') unless can_create_cluster? - cluster_params = params.merge(user: current_user, cluster_type: :project_type, projects: [project]) + cluster_params = params.merge(user: current_user).merge(clusterable_params) cluster_params[:provider_gcp_attributes].try do |provider| provider[:access_token] = access_token end @@ -27,9 +28,20 @@ module Clusters Clusters::Cluster.create(cluster_params) end + def clusterable + @clusterable ||= params.delete(:clusterable) + end + + def clusterable_params + case clusterable + when ::Project + { cluster_type: :project_type, projects: [clusterable] } + end + end + # EE would override this method - def can_create_cluster?(project) - project.clusters.empty? + def can_create_cluster? + clusterable.clusters.empty? end end end diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 6ee63db8eb9..3df43657fa0 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -11,8 +11,9 @@ module Clusters configure_provider create_gitlab_service_account! configure_kubernetes - cluster.save! + configure_project_service_account + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") rescue Kubeclient::HttpError => e @@ -24,7 +25,10 @@ module Clusters private def create_gitlab_service_account! - Clusters::Gcp::Kubernetes::CreateServiceAccountService.new(kube_client, rbac: create_rbac_cluster?).execute + Clusters::Gcp::Kubernetes::CreateServiceAccountService.gitlab_creator( + kube_client, + rbac: create_rbac_cluster? + ).execute end def configure_provider @@ -44,7 +48,20 @@ module Clusters end def request_kubernetes_token - Clusters::Gcp::Kubernetes::FetchKubernetesTokenService.new(kube_client).execute + Clusters::Gcp::Kubernetes::FetchKubernetesTokenService.new( + kube_client, + Clusters::Gcp::Kubernetes::GITLAB_ADMIN_TOKEN_NAME, + Clusters::Gcp::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE + ).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 diff --git a/app/services/clusters/gcp/kubernetes.rb b/app/services/clusters/gcp/kubernetes.rb index d014d73b3e8..90ed529670c 100644 --- a/app/services/clusters/gcp/kubernetes.rb +++ b/app/services/clusters/gcp/kubernetes.rb @@ -3,11 +3,12 @@ module Clusters module Gcp module Kubernetes - SERVICE_ACCOUNT_NAME = 'gitlab' - SERVICE_ACCOUNT_NAMESPACE = 'default' - SERVICE_ACCOUNT_TOKEN_NAME = 'gitlab-token' - CLUSTER_ROLE_BINDING_NAME = 'gitlab-admin' - CLUSTER_ROLE_NAME = 'cluster-admin' + GITLAB_SERVICE_ACCOUNT_NAME = 'gitlab' + GITLAB_SERVICE_ACCOUNT_NAMESPACE = 'default' + GITLAB_ADMIN_TOKEN_NAME = 'gitlab-token' + GITLAB_CLUSTER_ROLE_BINDING_NAME = 'gitlab-admin' + GITLAB_CLUSTER_ROLE_NAME = 'cluster-admin' + PROJECT_CLUSTER_ROLE_NAME = 'edit' end end 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 new file mode 100644 index 00000000000..a888fab2789 --- /dev/null +++ b/app/services/clusters/gcp/kubernetes/create_or_update_namespace_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Clusters + module Gcp + module Kubernetes + class CreateOrUpdateNamespaceService + def initialize(cluster:, kubernetes_namespace:) + @cluster = cluster + @kubernetes_namespace = kubernetes_namespace + @platform = cluster.platform + end + + def execute + configure_kubernetes_namespace + create_project_service_account + configure_kubernetes_token + + kubernetes_namespace.save! + rescue ::Kubeclient::HttpError => err + raise err unless err.error_code = 404 + end + + private + + attr_reader :cluster, :kubernetes_namespace, :platform + + def configure_kubernetes_namespace + kubernetes_namespace.configure_predefined_credentials + end + + def create_project_service_account + Clusters::Gcp::Kubernetes::CreateServiceAccountService.namespace_creator( + platform.kubeclient, + service_account_name: kubernetes_namespace.service_account_name, + service_account_namespace: kubernetes_namespace.namespace, + rbac: platform.rbac? + ).execute + end + + def configure_kubernetes_token + kubernetes_namespace.service_account_token = fetch_service_account_token + end + + def fetch_service_account_token + Clusters::Gcp::Kubernetes::FetchKubernetesTokenService.new( + platform.kubeclient, + kubernetes_namespace.token_name, + kubernetes_namespace.namespace + ).execute + end + end + end + end +end diff --git a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb b/app/services/clusters/gcp/kubernetes/create_service_account_service.rb index d17744591e6..dfc4bf7a358 100644 --- a/app/services/clusters/gcp/kubernetes/create_service_account_service.rb +++ b/app/services/clusters/gcp/kubernetes/create_service_account_service.rb @@ -4,46 +4,96 @@ module Clusters module Gcp module Kubernetes class CreateServiceAccountService - attr_reader :kubeclient, :rbac - - def initialize(kubeclient, rbac:) + 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 + @service_account_namespace = service_account_namespace + @token_name = token_name @rbac = rbac + @namespace_creator = namespace_creator + @role_binding_name = role_binding_name + end + + def self.gitlab_creator(kubeclient, rbac:) + self.new( + kubeclient, + service_account_name: Clusters::Gcp::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAME, + service_account_namespace: Clusters::Gcp::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE, + token_name: Clusters::Gcp::Kubernetes::GITLAB_ADMIN_TOKEN_NAME, + rbac: rbac + ) + end + + def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, rbac:) + self.new( + kubeclient, + service_account_name: service_account_name, + service_account_namespace: service_account_namespace, + token_name: "#{service_account_namespace}-token", + rbac: rbac, + namespace_creator: true, + role_binding_name: "gitlab-#{service_account_namespace}" + ) end 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_cluster_role_binding(cluster_role_binding_resource) if rbac + create_role_or_cluster_role_binding if rbac end private + attr_reader :kubeclient, :service_account_name, :service_account_namespace, :token_name, :rbac, :namespace_creator, :role_binding_name + + def ensure_project_namespace_exists + Gitlab::Kubernetes::Namespace.new( + service_account_namespace, + kubeclient + ).ensure_exists! + end + + def create_role_or_cluster_role_binding + if namespace_creator + kubeclient.create_role_binding(role_binding_resource) + else + kubeclient.create_cluster_role_binding(cluster_role_binding_resource) + end + end + def service_account_resource - Gitlab::Kubernetes::ServiceAccount.new(service_account_name, service_account_namespace).generate + Gitlab::Kubernetes::ServiceAccount.new( + service_account_name, + service_account_namespace + ).generate end def service_account_token_resource Gitlab::Kubernetes::ServiceAccountToken.new( - SERVICE_ACCOUNT_TOKEN_NAME, service_account_name, service_account_namespace).generate + token_name, + service_account_name, + service_account_namespace + ).generate end def cluster_role_binding_resource subjects = [{ kind: 'ServiceAccount', name: service_account_name, namespace: service_account_namespace }] Gitlab::Kubernetes::ClusterRoleBinding.new( - CLUSTER_ROLE_BINDING_NAME, - CLUSTER_ROLE_NAME, + Clusters::Gcp::Kubernetes::GITLAB_CLUSTER_ROLE_BINDING_NAME, + Clusters::Gcp::Kubernetes::GITLAB_CLUSTER_ROLE_NAME, subjects ).generate end - def service_account_name - SERVICE_ACCOUNT_NAME - end - - def service_account_namespace - SERVICE_ACCOUNT_NAMESPACE + def role_binding_resource + Gitlab::Kubernetes::RoleBinding.new( + name: role_binding_name, + role_name: Clusters::Gcp::Kubernetes::PROJECT_CLUSTER_ROLE_NAME, + namespace: service_account_namespace, + service_account_name: service_account_name + ).generate end end end diff --git a/app/services/clusters/gcp/kubernetes/fetch_kubernetes_token_service.rb b/app/services/clusters/gcp/kubernetes/fetch_kubernetes_token_service.rb index 9e09345c8dc..277cc4b788d 100644 --- a/app/services/clusters/gcp/kubernetes/fetch_kubernetes_token_service.rb +++ b/app/services/clusters/gcp/kubernetes/fetch_kubernetes_token_service.rb @@ -4,10 +4,12 @@ module Clusters module Gcp module Kubernetes class FetchKubernetesTokenService - attr_reader :kubeclient + attr_reader :kubeclient, :service_account_token_name, :namespace - def initialize(kubeclient) + def initialize(kubeclient, service_account_token_name, namespace) @kubeclient = kubeclient + @service_account_token_name = service_account_token_name + @namespace = namespace end def execute @@ -18,7 +20,7 @@ module Clusters private def get_secret - kubeclient.get_secret(SERVICE_ACCOUNT_TOKEN_NAME, SERVICE_ACCOUNT_NAMESPACE).as_json + kubeclient.get_secret(service_account_token_name, namespace).as_json rescue Kubeclient::HttpError => err raise err unless err.error_code == 404 diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb deleted file mode 100644 index bb3f605da28..00000000000 --- a/app/services/create_deployment_service.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -class CreateDeploymentService - attr_reader :job - - delegate :expanded_environment_name, - :variables, - :project, - to: :job - - def initialize(job) - @job = job - end - - def execute - return unless executable? - - ActiveRecord::Base.transaction do - environment.external_url = expanded_environment_url if - expanded_environment_url - - environment.fire_state_event(action) - - break unless environment.save - break if environment.stopped? - - deploy.tap(&:update_merge_request_metrics!) - end - end - - private - - def executable? - project && job.environment.present? && environment - end - - def deploy - project.deployments.create( - environment: environment, - ref: job.ref, - tag: job.tag, - sha: job.sha, - user: job.user, - deployable: job, - on_stop: on_stop) - end - - def environment - @environment ||= job.persisted_environment - end - - def environment_options - @environment_options ||= job.options&.dig(:environment) || {} - end - - def expanded_environment_url - return @expanded_environment_url if defined?(@expanded_environment_url) - - @expanded_environment_url = - ExpandVariables.expand(environment_url, variables) if environment_url - end - - def environment_url - environment_options[:url] - end - - def on_stop - environment_options[:on_stop] - end - - def action - environment_options[:action] || 'start' - end -end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 3e8b9f84042..c388913ae65 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -3,6 +3,14 @@ class IssuableBaseService < BaseService private + attr_accessor :params, :skip_milestone_email + + def initialize(project, user = nil, params = {}) + super + + @skip_milestone_email = @params.delete(:skip_milestone_email) + end + def filter_params(issuable) ability_name = :"admin_#{issuable.to_ability_name}" diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b54b0bf6ef6..fba252b0bae 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -48,6 +48,8 @@ module Issues notification_service.async.relabeled_issue(issue, added_labels, current_user) end + handle_milestone_change(issue) + added_mentions = issue.mentioned_users - old_mentioned_users if added_mentions.present? @@ -91,6 +93,18 @@ module Issues private + def handle_milestone_change(issue) + return if skip_milestone_email + + return unless issue.previous_changes.include?('milestone_id') + + if issue.milestone.nil? + notification_service.async.removed_milestone_issue(issue, current_user) + else + notification_service.async.changed_milestone_issue(issue, issue.milestone, current_user) + end + end + # rubocop: disable CodeReuse/ActiveRecord def get_issue_if_allowed(id, board_group_id = nil) return unless id diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index f01872b205e..53768ff2cbe 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -87,11 +87,8 @@ module MergeRequests filter_merge_requests(merge_requests).each do |merge_request| if branch_and_project_match?(merge_request) || @push.force_push? merge_request.reload_diff(current_user) - else - mr_commit_ids = merge_request.commit_shas - push_commit_ids = @commits.map(&:id) - matches = mr_commit_ids & push_commit_ids - merge_request.reload_diff(current_user) if matches.any? + elsif merge_request.includes_any_commits?(push_commit_ids) + merge_request.reload_diff(current_user) end merge_request.mark_as_unchecked @@ -104,6 +101,10 @@ module MergeRequests end # rubocop: enable CodeReuse/ActiveRecord + def push_commit_ids + @push_commit_ids ||= @commits.map(&:id) + end + def branch_and_project_match?(merge_request) merge_request.source_project == @project && merge_request.source_branch == @push.branch_name diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb index b4d48fe92ad..b47d8f3f63a 100644 --- a/app/services/merge_requests/reload_diffs_service.rb +++ b/app/services/merge_requests/reload_diffs_service.rb @@ -36,7 +36,10 @@ module MergeRequests # Remove cache for all diffs on this MR. Do not use the association on the # model, as that will interfere with other actions happening when # reloading the diff. - MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| + MergeRequestDiff + .where(merge_request: merge_request) + .preload(merge_request: :target_project) + .find_each do |merge_request_diff| next if merge_request_diff == new_diff cacheable_collection(merge_request_diff).clear_cache diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index b112edbce7f..aacaf10d09c 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -58,6 +58,8 @@ module MergeRequests merge_request.mark_as_unchecked end + handle_milestone_change(merge_request) + added_labels = merge_request.labels - old_labels if added_labels.present? notification_service.async.relabeled_merge_request( @@ -105,6 +107,18 @@ module MergeRequests private + def handle_milestone_change(merge_request) + return if skip_milestone_email + + return unless merge_request.previous_changes.include?('milestone_id') + + if merge_request.milestone.nil? + notification_service.async.removed_milestone_merge_request(merge_request, current_user) + else + notification_service.async.changed_milestone_merge_request(merge_request, merge_request.milestone, current_user) + end + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 7cda802c120..87c7a282081 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -4,7 +4,7 @@ module Milestones class DestroyService < Milestones::BaseService def execute(milestone) Milestone.transaction do - update_params = { milestone: nil } + update_params = { milestone: nil, skip_milestone_email: true } milestone.issues.each do |issue| Issues::UpdateService.new(parent, current_user, update_params).execute(issue) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 50fa373025b..fb9c18ea75d 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -129,6 +129,14 @@ class NotificationService relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email) end + def removed_milestone_issue(issue, current_user) + removed_milestone_resource_email(issue, current_user, :removed_milestone_issue_email) + end + + def changed_milestone_issue(issue, new_milestone, current_user) + changed_milestone_resource_email(issue, new_milestone, current_user, :changed_milestone_issue_email) + end + # When create a merge request we should send an email to: # # * mr author @@ -138,7 +146,6 @@ class NotificationService # * users with custom level checked with "new merge request" # # In EE, approvers of the merge request are also included - # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, :new_merge_request_email) end @@ -208,6 +215,14 @@ class NotificationService relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email) end + def removed_milestone_merge_request(merge_request, current_user) + removed_milestone_resource_email(merge_request, current_user, :removed_milestone_merge_request_email) + end + + def changed_milestone_merge_request(merge_request, new_milestone, current_user) + changed_milestone_resource_email(merge_request, new_milestone, current_user, :changed_milestone_merge_request_email) + end + def close_mr(merge_request, current_user) close_resource_email(merge_request, current_user, :closed_merge_request_email) end @@ -500,6 +515,30 @@ class NotificationService end end + def removed_milestone_resource_email(target, current_user, method) + recipients = NotificationRecipientService.build_recipients( + target, + current_user, + action: 'removed_milestone' + ) + + recipients.each do |recipient| + mailer.send(method, recipient.user.id, target.id, current_user.id).deliver_later + end + end + + def changed_milestone_resource_email(target, milestone, current_user, method) + recipients = NotificationRecipientService.build_recipients( + target, + current_user, + action: 'changed_milestone' + ) + + recipients.each do |recipient| + mailer.send(method, recipient.user.id, target.id, milestone, current_user.id).deliver_later + end + end + def reopen_resource_email(target, current_user, method, status) recipients = NotificationRecipientService.build_recipients(target, current_user, action: "reopen") diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb new file mode 100644 index 00000000000..aa7fcca1e2a --- /dev/null +++ b/app/services/update_deployment_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class UpdateDeploymentService + attr_reader :deployment + attr_reader :deployable + + delegate :environment, to: :deployment + delegate :variables, to: :deployable + + def initialize(deployment) + @deployment = deployment + @deployable = deployment.deployable + end + + def execute + deployment.create_ref + deployment.invalidate_cache + + ActiveRecord::Base.transaction do + environment.external_url = expanded_environment_url if + expanded_environment_url + + environment.fire_state_event(action) + + break unless environment.save + break if environment.stopped? + + deployment.tap(&:update_merge_request_metrics!) + end + end + + private + + def environment_options + @environment_options ||= deployable.options&.dig(:environment) || {} + end + + def expanded_environment_url + return @expanded_environment_url if defined?(@expanded_environment_url) + return unless environment_url + + @expanded_environment_url = + ExpandVariables.expand(environment_url, variables) + end + + def environment_url + environment_options[:url] + end + + def action + environment_options[:action] || 'start' + end +end diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 97be658cd34..adb496495d1 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -41,5 +41,13 @@ The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>. = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') + .form-group + = f.label :archive_builds_in_human_readable, 'Archive builds in', class: 'label-bold' + = f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never' + .form-text.text-muted + Set the duration when build gonna be considered old. Archived builds cannot be retried. + Make it empty to never expire builds. It has to be larger than 1 day. + The default unit is in seconds, but you can define an alternative. For example: + <code>4 mins 2 sec</code>, <code>2h42min</code>. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 243e8cd9ba0..7037c80aa6b 100644 --- a/app/views/projects/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -12,4 +12,4 @@ = s_('ClusterIntegration|Remove Kubernetes cluster integration') %p = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.") - = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")}) + = link_to(s_('ClusterIntegration|Remove integration'), clusterable.cluster_path(@cluster), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")}) diff --git a/app/views/projects/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 73cfea0ef92..160c5f009a7 100644 --- a/app/views/projects/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -7,9 +7,3 @@ .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details") - -- if show_cluster_security_warning? - .js-cluster-security-warning.alert.alert-block.alert-dismissable.bs-callout.bs-callout-warning - %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::CLUSTER_SECURITY_WARNING, dismiss_endpoint: user_callouts_path } } × - = s_("ClusterIntegration|The default cluster configuration grants access to many functionalities needed to successfully build and deploy a containerised application.") - = link_to s_("More information"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index 2d7f7c6b1fb..facbcb7fc59 100644 --- a/app/views/projects/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -2,7 +2,7 @@ .table-section.section-30 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") .table-mobile-content - = link_to cluster.name, namespace_project_cluster_path(@project.namespace, @project, cluster) + = link_to cluster.name, cluster.show_path .table-section.section-30 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope") .table-mobile-content= cluster.environment_scope @@ -16,7 +16,7 @@ class: "#{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}", "aria-label": s_("ClusterIntegration|Toggle Kubernetes Cluster"), disabled: !cluster.can_toggle_cluster?, - data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } } + data: { endpoint: clusterable.cluster_path(cluster, format: :json) } } %input.js-project-feature-toggle-input{ type: "hidden", value: cluster.enabled? } = icon("spinner spin", class: "loading-icon") %span.toggle-icon diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml index b8a3556a206..800e76d92ef 100644 --- a/app/views/projects/clusters/_empty_state.html.haml +++ b/app/views/clusters/clusters/_empty_state.html.haml @@ -7,6 +7,6 @@ - link_to_help_page = link_to(_('Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') %p= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page} - - if can?(current_user, :create_cluster, @project) + - if clusterable.can_create_cluster? .text-center - = link_to s_('ClusterIntegration|Add Kubernetes cluster'), new_project_cluster_path(@project), class: 'btn btn-success' + = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success' diff --git a/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 73b11d509d3..73b11d509d3 100644 --- a/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/clusters/clusters/_integration_form.html.haml index d0a553e3414..5e451f60c9d 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/clusters/clusters/_integration_form.html.haml @@ -1,4 +1,4 @@ -= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| += form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| = form_errors(@cluster) .form-group %h5= s_('ClusterIntegration|Integration status') @@ -13,7 +13,7 @@ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.') - - if has_multiple_clusters?(@project) + - if has_multiple_clusters? .form-group %h5= s_('ClusterIntegration|Environment scope') = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') @@ -23,7 +23,7 @@ .form-group = field.submit _('Save changes'), class: 'btn btn-success' - - unless has_multiple_clusters?(@project) + - unless has_multiple_clusters? %h5= s_('ClusterIntegration|Environment scope') %p %code * diff --git a/app/views/projects/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml index 3d10348212f..3d10348212f 100644 --- a/app/views/projects/clusters/_sidebar.html.haml +++ b/app/views/clusters/clusters/_sidebar.html.haml diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 171ceeceb68..8ed4666e79a 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -12,14 +12,14 @@ %p= link_to('Select a different Google account', @authorize_url) -= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: create_gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| += form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| = form_errors(@gcp_cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') .form-group = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' - = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?, placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field| .form-group @@ -64,7 +64,7 @@ .form-group .form-check = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true - = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') diff --git a/app/views/projects/clusters/gcp/_header.html.haml b/app/views/clusters/clusters/gcp/_header.html.haml index a2ad3cd64df..a2ad3cd64df 100644 --- a/app/views/projects/clusters/gcp/_header.html.haml +++ b/app/views/clusters/clusters/gcp/_header.html.haml diff --git a/app/views/projects/clusters/gcp/_show.html.haml b/app/views/clusters/clusters/gcp/_show.html.haml index 779c9c245c1..ca55ccb8fdf 100644 --- a/app/views/projects/clusters/gcp/_show.html.haml +++ b/app/views/clusters/clusters/gcp/_show.html.haml @@ -6,7 +6,7 @@ %span.input-group-append = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') -= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| += form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| = form_errors(@cluster) = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| @@ -40,7 +40,7 @@ .form-group .form-check = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') diff --git a/app/views/projects/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index a55de84b5cd..a55de84b5cd 100644 --- a/app/views/projects/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml diff --git a/app/views/projects/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index a38003f5750..eeeef6bd824 100644 --- a/app/views/projects/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -19,9 +19,9 @@ .tab-content.gitlab-tab-content .tab-pane{ id: 'create-gcp-cluster-pane', class: active_when(active_tab == 'gcp'), role: 'tabpanel' } - = render 'projects/clusters/gcp/header' + = render 'clusters/clusters/gcp/header' - if @valid_gcp_token - = render 'projects/clusters/gcp/form' + = render 'clusters/clusters/gcp/form' - elsif @authorize_url .signin-with-google = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px'), @authorize_url) @@ -32,5 +32,5 @@ = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link } .tab-pane{ id: 'add-user-cluster-pane', class: active_when(active_tab == 'user'), role: 'tabpanel' } - = render 'projects/clusters/user/header' - = render 'projects/clusters/user/form' + = render 'clusters/clusters/user/header' + = render 'clusters/clusters/user/form' diff --git a/app/views/projects/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index eddd3613c5f..1e1157c34bd 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -1,24 +1,25 @@ - @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs "Kubernetes Clusters", project_clusters_path(@project) +- add_to_breadcrumbs "Kubernetes Clusters", clusterable.index_path - breadcrumb_title @cluster.name - page_title _("Kubernetes Cluster") +- manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project - expanded = Rails.env.test? -- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) +- status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, - install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm), - install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress), - install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus), - install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner), - install_jupyter_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :jupyter), + install_helm_path: clusterable.install_applications_cluster_path(@cluster, :helm), + install_ingress_path: clusterable.install_applications_cluster_path(@cluster, :ingress), + install_prometheus_path: clusterable.install_applications_cluster_path(@cluster, :prometheus), + install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner), + install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter), toggle_status: @cluster.enabled? ? 'true': 'false', cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'), ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'), - manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } } + manage_prometheus_path: manage_prometheus_path } } .js-cluster-application-notice .flash-container @@ -38,9 +39,9 @@ %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content - if @cluster.managed? - = render 'projects/clusters/gcp/show' + = render 'clusters/clusters/gcp/show' - else - = render 'projects/clusters/user/show' + = render 'clusters/clusters/user/show' %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 54a6e685bb0..e4758938059 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,9 +1,9 @@ -= form_for @user_cluster, url: create_user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| += form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field| = form_errors(@user_cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - - if has_multiple_clusters?(@project) + - if has_multiple_clusters? .form-group = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') @@ -28,7 +28,7 @@ .form-group .form-check = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') diff --git a/app/views/projects/clusters/user/_header.html.haml b/app/views/clusters/clusters/user/_header.html.haml index 749177fa6c1..749177fa6c1 100644 --- a/app/views/projects/clusters/user/_header.html.haml +++ b/app/views/clusters/clusters/user/_header.html.haml diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/clusters/clusters/user/_show.html.haml index 5b57f7ceb7d..ad8c35e32e3 100644 --- a/app/views/projects/clusters/user/_show.html.haml +++ b/app/views/clusters/clusters/user/_show.html.haml @@ -1,4 +1,4 @@ -= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| += form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| = form_errors(@cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' @@ -29,7 +29,7 @@ .form-group .form-check = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 78a1d1a0553..2fcb1d1fd2b 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,5 +1,5 @@ - if event.visible_to_user?(current_user) - .event-item{ class: event_row_class(event) } + .event-item .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 829a3da1558..96d6553a2ac 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,20 +1,19 @@ = icon_for_profile_event(event) -.event-title - %span.author_name= link_to_author(event) - %span{ class: event.action_name } += event_user_info(event) + +.event-title.d-flex.flex-wrap + = inline_event_icon(event) - if event.target - = event.action_name - %strong - = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title do - = event.target_type.titleize.downcase - = event.target.reference_link_text + %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + = event.action_name + %span.event-target-type.append-right-4= event.target_type.titleize.downcase + = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do + = event.target.reference_link_text + - unless event.milestone? + %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe - else - = event_action_name(event) + %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + = event_action_name(event) = render "events/event_scope", event: event - -- if event.target.respond_to?(:title) - .event-body - .event-note - = event.target.title diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 6ad7e157131..2f156603414 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,8 +1,10 @@ = icon_for_profile_event(event) -.event-title - %span.author_name= link_to_author(event) - %span{ class: event.action_name } += event_user_info(event) + +.event-title.d-flex.flex-wrap + = inline_event_icon(event) + %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event_action_name(event) - if event.project diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index cdacd998a69..fb0d2c3b8b0 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,9 +1,13 @@ = icon_for_profile_event(event) -.event-title - %span.author_name= link_to_author(event) - = event.action_name += event_user_info(event) + +.event-title.d-flex.flex-wrap + = inline_event_icon(event) + %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + = event.action_name = event_note_title_html(event) + %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe = render "events/event_scope", event: event diff --git a/app/views/events/event/_private.html.haml b/app/views/events/event/_private.html.haml index ccd2aacb4ea..d91f30c07cb 100644 --- a/app/views/events/event/_private.html.haml +++ b/app/views/events/event/_private.html.haml @@ -1,10 +1,11 @@ -.event-inline.event-item +.event-item .event-item-timestamp = time_ago_with_tooltip(event.created_at) - .system-note-image= sprite_icon('eye-slash', size: 16, css_class: 'icon') + .system-note-image= sprite_icon('eye-slash', size: 24, css_class: 'icon') - .event-title - - author_name = capture do - %span.author_name= link_to_author(event) - = s_('Profiles|%{author_name} made a private contribution').html_safe % { author_name: author_name } + = event_user_info(event) + + .event-title.d-flex.flex-wrap + = inline_event_icon(event) + = s_('Profiles|Made a private contribution') diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 5f0ee79cd9b..82693ec832e 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -2,13 +2,15 @@ = icon_for_profile_event(event) -.event-title - %span.author_name= link_to_author(event) - %span.pushed #{event.action_name} #{event.ref_type} - %strong += event_user_info(event) + +.event-title.d-flex.flex-wrap + = inline_event_icon(event) + %span.event-type.d-inline-block.append-right-4.pushed #{event.action_name} #{event.ref_type} + %span - commits_link = project_commits_path(project, event.ref_name) - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) - = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' + = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name append-right-4' = render "events/event_scope", event: event diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 5b78ce910b8..4df3d831942 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -6,7 +6,7 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || search.present? || subscribed.present? -- if can_admin_label +- if @labels.present? && can_admin_label - content_for(:header_content) do .nav-controls = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success" diff --git a/app/views/notify/changed_milestone_issue_email.html.haml b/app/views/notify/changed_milestone_issue_email.html.haml new file mode 100644 index 00000000000..7d5425fc72d --- /dev/null +++ b/app/views/notify/changed_milestone_issue_email.html.haml @@ -0,0 +1,3 @@ +%p + Milestone changed to + %strong= link_to(@milestone.name, @milestone_url) diff --git a/app/views/notify/changed_milestone_issue_email.text.erb b/app/views/notify/changed_milestone_issue_email.text.erb new file mode 100644 index 00000000000..c5fc0b61518 --- /dev/null +++ b/app/views/notify/changed_milestone_issue_email.text.erb @@ -0,0 +1 @@ +Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> ) diff --git a/app/views/notify/changed_milestone_merge_request_email.html.haml b/app/views/notify/changed_milestone_merge_request_email.html.haml new file mode 100644 index 00000000000..7d5425fc72d --- /dev/null +++ b/app/views/notify/changed_milestone_merge_request_email.html.haml @@ -0,0 +1,3 @@ +%p + Milestone changed to + %strong= link_to(@milestone.name, @milestone_url) diff --git a/app/views/notify/changed_milestone_merge_request_email.text.erb b/app/views/notify/changed_milestone_merge_request_email.text.erb new file mode 100644 index 00000000000..c5fc0b61518 --- /dev/null +++ b/app/views/notify/changed_milestone_merge_request_email.text.erb @@ -0,0 +1 @@ +Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> ) diff --git a/app/views/notify/removed_milestone_issue_email.html.haml b/app/views/notify/removed_milestone_issue_email.html.haml new file mode 100644 index 00000000000..7e9205b6491 --- /dev/null +++ b/app/views/notify/removed_milestone_issue_email.html.haml @@ -0,0 +1,2 @@ +%p + Milestone removed diff --git a/app/views/notify/removed_milestone_issue_email.text.erb b/app/views/notify/removed_milestone_issue_email.text.erb new file mode 100644 index 00000000000..0b83ed7a4c5 --- /dev/null +++ b/app/views/notify/removed_milestone_issue_email.text.erb @@ -0,0 +1 @@ +Milestone removed diff --git a/app/views/notify/removed_milestone_merge_request_email.html.haml b/app/views/notify/removed_milestone_merge_request_email.html.haml new file mode 100644 index 00000000000..7e9205b6491 --- /dev/null +++ b/app/views/notify/removed_milestone_merge_request_email.html.haml @@ -0,0 +1,2 @@ +%p + Milestone removed diff --git a/app/views/notify/removed_milestone_merge_request_email.text.erb b/app/views/notify/removed_milestone_merge_request_email.text.erb new file mode 100644 index 00000000000..0b83ed7a4c5 --- /dev/null +++ b/app/views/notify/removed_milestone_merge_request_email.text.erb @@ -0,0 +1 @@ +Milestone removed diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index f5685d3b50d..0b10c66777a 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -105,10 +105,10 @@ = icon('remove', class: 'cred') - elsif job.scheduled? .btn-group - .btn.btn-default.has-tooltip{ disabled: true, - title: job.scheduled_at } + .btn.btn-default{ disabled: true } = sprite_icon('planning') - = duration_in_numbers(job.execute_in) + %time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 } + = duration_in_numbers(job.execute_in) - confirmation_message = s_("DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes.") % { job_name: job.name } = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index 281e042c915..1bd538a08ff 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,4 +1,4 @@ -- if can?(current_user, :create_deployment, deployment) && deployment.deployable +- if can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do - if deployment.last? diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 06ee883d6dc..2c6484c2c99 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 can_admin_label +- if @labels.present? && 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/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 5d1bbb077af..515499956a2 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -34,7 +34,7 @@ .fade-left= icon('angle-left') .fade-right= icon('angle-right') %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs - %li.notes-tab + %li.notes-tab.qa-notes-tab = tab_link_for @merge_request, :show, force_link: @commit.present? do Discussion %span.badge.badge-pill= @merge_request.related_notes.user.count @@ -48,7 +48,7 @@ = tab_link_for @merge_request, :pipelines do Pipelines %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size - %li.diffs-tab + %li.diffs-tab.qa-diffs-tab = tab_link_for @merge_request, :diffs do Changes %span.badge.badge-pill= @merge_request.diff_size diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 95bba47802c..66e202103a9 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -61,12 +61,14 @@ %td.responsive-table-cell.build-failure{ data: { column: _("Failure")} } = build.present.callout_failure_message %td.responsive-table-cell.build-actions - = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do - = icon('repeat') - %tr.build-trace-row.responsive-table-border-end - %td - %td.responsive-table-cell.build-trace-container{ colspan: 4 } - %pre.build-trace.build-trace-rounded - %code.bash.js-build-output - = build_summary(build) + - if can?(current_user, :update_build, job) + = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do + = icon('repeat') + - if can?(current_user, :read_build, job) + %tr.build-trace-row.responsive-table-border-end + %td + %td.responsive-table-cell.build-trace-container{ colspan: 4 } + %pre.build-trace.build-trace-rounded + %code.bash.js-build-output + = build_summary(build) = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml deleted file mode 100644 index f79f3af36d4..00000000000 --- a/app/views/projects/tree/_blob_item.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- is_lfs_blob = @lfs_blob_ids.include?(blob_item.id) -%tr{ class: "tree-item #{tree_hex_class(blob_item)}" } - %td.tree-item-file-name - = tree_icon(type, blob_item.mode, blob_item.name) - - file_name = blob_item.name - = link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), class: 'str-truncated', title: file_name do - %span= file_name - - if is_lfs_blob - %span.badge.label-lfs.prepend-left-5 LFS - %td.d-none.d-sm-table-cell.tree-commit - %td.tree-time-ago.cgray.text-right - = render 'projects/tree/spinner' diff --git a/app/views/projects/tree/_spinner.html.haml b/app/views/projects/tree/_spinner.html.haml deleted file mode 100644 index b47ad0f41e4..00000000000 --- a/app/views/projects/tree/_spinner.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%span.log_loading.hide - %i.fa.fa-spinner.fa-spin - Loading commit data... diff --git a/app/views/projects/tree/_submodule_item.html.haml b/app/views/projects/tree/_submodule_item.html.haml deleted file mode 100644 index e563c8c4036..00000000000 --- a/app/views/projects/tree/_submodule_item.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%tr.tree-item - %td.tree-item-file-name - %i.fa.fa-archive.fa-fw - = submodule_link(submodule_item, @ref) - %td - %td.d-none.d-sm-table-cell diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml deleted file mode 100644 index ce0cd95b468..00000000000 --- a/app/views/projects/tree/_tree_item.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -%tr{ class: "tree-item #{tree_hex_class(tree_item)}" } - %td.tree-item-file-name - = tree_icon(type, tree_item.mode, tree_item.name) - - path = flatten_tree(@path, tree_item) - = link_to project_tree_path(@project, tree_join(@id || @commit.id, path)), class: 'str-truncated', title: path do - %span= path - %td.d-none.d-sm-table-cell.tree-commit - %td.tree-time-ago.text-right - = render 'projects/tree/spinner' diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml index 0a5c6f048f7..8a27ea66523 100644 --- a/app/views/projects/tree/_tree_row.html.haml +++ b/app/views/projects/tree/_tree_row.html.haml @@ -1,6 +1,27 @@ -- if tree_row.type == :tree - = render partial: 'projects/tree/tree_item', object: tree_row, as: 'tree_item', locals: { type: 'folder' } -- elsif tree_row.type == :blob - = render partial: 'projects/tree/blob_item', object: tree_row, as: 'blob_item', locals: { type: 'file' } -- elsif tree_row.type == :commit - = render partial: 'projects/tree/submodule_item', object: tree_row, as: 'submodule_item' +- tree_row_name = tree_row.name +- tree_row_type = tree_row.type + +%tr{ class: "tree-item file_#{hexdigest(tree_row_name)}" } + %td.tree-item-file-name + - if tree_row_type == :tree + = tree_icon('folder', tree_row.mode, tree_row.name) + - path = flatten_tree(@path, tree_row) + %a.str-truncated{ href: fast_project_tree_path(@project, tree_join(@id || @commit.id, path)), title: path } + %span= path + + - elsif tree_row_type == :blob + = tree_icon('file', tree_row.mode, tree_row_name) + %a.str-truncated{ href: fast_project_blob_path(@project, tree_join(@id || @commit.id, tree_row_name)), title: tree_row_name } + %span= tree_row_name + - if @lfs_blob_ids.include?(tree_row.id) + %span.badge.label-lfs.prepend-left-5 LFS + + - elsif tree_row_type == :commit + = tree_icon('archive', tree_row.mode, tree_row.name) + = submodule_link(tree_row, @ref) + + %td.d-none.d-sm-table-cell.tree-commit + %td.tree-time-ago.text-right + %span.log_loading.hide + %i.fa.fa-spinner.fa-spin + Loading commit data... diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 6138914206b..19159684420 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -19,13 +19,13 @@ ":value" => "label.id" } .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", - "v-bind:data-selected" => "selectedLabels", + ":data-selected" => "selectedLabels", + ":data-labels" => "issue.assignableLabelsEndpoint", data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project&.try(:id), - labels: labels_filter_path_with_defaults, namespace_path: @namespace_path, project_path: @project.try(:path) } } %span.dropdown-toggle-text diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index b629ceafeb3..9133ce8ed22 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -6,6 +6,9 @@ .text-content %h4= _("Labels can be applied to issues and merge requests to categorize them.") %p= _("You can also star a label to make it a priority label.") - - if can?(current_user, :admin_label, @project) - = link_to _('New label'), new_project_label_path(@project), class: 'btn btn-success', title: _('New label'), id: 'new_label_link' - = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn btn-success btn-inverted', title: _('Generate a default set of labels'), id: 'generate_labels_link' + .text-center + - if can?(current_user, :admin_label, @project) + = link_to _('New label'), new_project_label_path(@project), class: 'btn btn-success', title: _('New label'), id: 'new_label_link' + = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn btn-success btn-inverted', title: _('Generate a default set of labels'), id: 'generate_labels_link' + - if can?(current_user, :admin_label, @group) + = link_to _('New label'), new_group_label_path(@group), class: 'btn btn-success', title: _('New label'), id: 'new_label_link' diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index b89194bcc67..3b5c13ed93a 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -21,3 +21,5 @@ - if params[:visibility_level].present? = hidden_field_tag :visibility_level, params[:visibility_level] + + = render_if_exists 'shared/projects/search_fields' diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index f8b3754840d..cf525f2bb2d 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -11,8 +11,8 @@ - if can?(current_user, :read_cross_project) .activities-block - .content-block - %h5.prepend-top-10 + .border-bottom.prepend-top-16 + %h5 = s_('UserProfile|Recent contributions') .overview-content-list{ data: { href: user_path } } .center.light.loading @@ -22,7 +22,7 @@ .col-md-12.col-lg-6 .projects-block - .content-block + .border-bottom.prepend-top-16 %h4 = s_('UserProfile|Personal projects') .overview-content-list{ data: { href: user_projects_path } } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f21789de37d..953ab95735b 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -28,6 +28,7 @@ - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation - gcp_cluster:cluster_wait_for_ingress_ip_address +- gcp_cluster:cluster_platform_configure - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -72,6 +73,8 @@ - pipeline_processing:update_head_pipeline_for_merge_request - pipeline_processing:ci_build_schedule +- deployment:deployments_success + - repository_check:repository_check_clear - repository_check:repository_check_batch - repository_check:repository_check_single_repository diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index c17608f7378..9a865fea621 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -10,13 +10,27 @@ class BuildSuccessWorker def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| create_deployment(build) if build.has_environment? + stop_environment(build) if build.stops_environment? end end # rubocop: enable CodeReuse/ActiveRecord private + ## + # Deprecated: + # As of 11.5, we started creating a deployment record when ci_builds record is created. + # Therefore we no longer need to create a deployment, after a build succeeded. + # We're leaving this code for the transition period, but we can remove this code in 11.6. def create_deployment(build) - CreateDeploymentService.new(build).execute + build.create_deployment.try do |deployment| + deployment.succeed + end + end + + ## + # TODO: This should be processed in DeploymentSuccessWorker once we started storing `action` value in `deployments` records + def stop_environment(build) + build.persisted_environment.fire_state_event(:stop) end end diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_platform_configure_worker.rb new file mode 100644 index 00000000000..68e8335a09d --- /dev/null +++ b/app/workers/cluster_platform_configure_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ClusterPlatformConfigureWorker + include ApplicationWorker + include ClusterQueue + + 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 + end + + rescue ::Kubeclient::HttpError => err + Rails.logger.error "Failed to create/update Kubernetes Namespace. id: #{kubernetes_namespace.id} message: #{err.message}" + end +end diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 59de7903c1c..3d5894b73ec 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -9,6 +9,8 @@ class ClusterProvisionWorker cluster.provider.try do |provider| Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? end + + ClusterPlatformConfigureWorker.perform_async(cluster.id) if cluster.user? end end end diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb new file mode 100644 index 00000000000..da517f3fb26 --- /dev/null +++ b/app/workers/deployments/success_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Deployments + class SuccessWorker + include ApplicationWorker + + queue_namespace :deployment + + def perform(deployment_id) + Deployment.find_by_id(deployment_id).try do |deployment| + break unless deployment.success? + + UpdateDeploymentService.new(deployment).execute + end + end + end +end |