diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-27 12:09:14 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-27 12:09:14 +0000 |
commit | 95ff19a65c5236863e4c7c7e198bfc1e2fa70f07 (patch) | |
tree | e543a0b23941611b93a7d435b7644eafcdd8cbeb | |
parent | 2df573afed782aebce8c020d92b42e9da7d2868e (diff) | |
download | gitlab-ce-95ff19a65c5236863e4c7c7e198bfc1e2fa70f07.tar.gz |
Add latest changes from gitlab-org/gitlab@master
104 files changed, 2299 insertions, 1040 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 5680c223c48..337816dede9 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -157,6 +157,7 @@ - "{,ee/}fixtures/**/*" - "{,ee/}rubocop/**/*" - "{,ee/}spec/**/*" + - "{,spec/}tooling/**/*" .code-patterns: &code-patterns - "{package.json,yarn.lock}" @@ -203,6 +204,7 @@ - "{,ee/}fixtures/**/*" - "{,ee/}rubocop/**/*" - "{,ee/}spec/**/*" + - "{,spec/}tooling/**/*" .code-qa-patterns: &code-qa-patterns - "{package.json,yarn.lock}" @@ -248,6 +250,7 @@ - "{,ee/}fixtures/**/*" - "{,ee/}rubocop/**/*" - "{,ee/}spec/**/*" + - "{,spec/}tooling/**/*" # QA changes - ".dockerignore" - "qa/**/*" diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index def45026b35..731ed2ddd01 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -1,8 +1,8 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; -import CiLintResults from './ci_lint_results.vue'; -import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; export default { components: { @@ -56,7 +56,7 @@ export default { lintCI: { valid, errors, warnings, jobs }, }, } = await this.$apollo.mutate({ - mutation: lintCIMutation, + mutation: lintCiMutation, variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun }, }); @@ -119,6 +119,7 @@ export default { <ci-lint-results v-if="showingResults" + class="col-sm-12 gl-mt-5" :valid="valid" :jobs="jobs" :errors="errors" diff --git a/app/assets/javascripts/ci_lint/graphql/resolvers.js b/app/assets/javascripts/ci_lint/graphql/resolvers.js deleted file mode 100644 index 126b4c664b2..00000000000 --- a/app/assets/javascripts/ci_lint/graphql/resolvers.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -const resolvers = { - Mutation: { - lintCI: (_, { endpoint, content, dry_run }) => { - return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ - valid: data.valid, - errors: data.errors, - warnings: data.warnings, - jobs: data.jobs.map(job => { - const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; - - return { - name: job.name, - stage: job.stage, - beforeScript: job.before_script, - script: job.script, - afterScript: job.after_script, - tagList: job.tag_list, - environment: job.environment, - when: job.when, - allowFailure: job.allow_failure, - only, - except: job.except, - __typename: 'CiLintJob', - }; - }), - __typename: 'CiLintContent', - })); - }, - }, -}; - -export default resolvers; diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index e4cda4cb369..274aab45deb 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { resolvers } from '~/pipeline_editor/graphql/resolvers'; + import CiLint from './components/ci_lint.vue'; -import resolvers from './graphql/resolvers'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 09baf16ade9..3fa71feb10d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; @@ -316,7 +317,7 @@ export default { 'setHighlightedRow', 'cacheTreeListWidth', 'scrollToFile', - 'toggleShowTreeList', + 'setShowTreeList', 'navigateToDiffFileIndex', ]), navigateToDiffFileNumber(number) { @@ -343,7 +344,7 @@ export default { this.fetchDiffFilesMeta() .then(({ real_size }) => { this.diffFilesLength = parseInt(real_size, 10); - if (toggleTree) this.hideTreeListIfJustOneFile(); + if (toggleTree) this.setTreeDisplay(); this.startDiffRendering(); }) @@ -353,6 +354,7 @@ export default { this.fetchDiffFilesBatch() .then(() => { + if (toggleTree) this.setTreeDisplay(); // Guarantee the discussions are assigned after the batch finishes. // Just watching the length of the discussions or the diff files // isn't enough, because with split diff loading, neither will @@ -422,12 +424,17 @@ export default { this.scrollToFile(this.diffFiles[targetIndex].file_path); } }, - hideTreeListIfJustOneFile() { + setTreeDisplay() { const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); + let showTreeList = true; - if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') { - this.toggleShowTreeList(false); + if (storedTreeShow !== null) { + showTreeList = Boolean(storedTreeShow); + } else if (!bp.isDesktop() || (!this.isBatchLoading && this.diffFiles.length <= 1)) { + showTreeList = false; } + + return this.setShowTreeList({ showTreeList, saving: false }); }, }, minTreeWidth: MIN_TREE_WIDTH, diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 700d5ec86c8..f3cc359a679 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -65,11 +65,7 @@ export default { polyfillSticky(this.$el); }, methods: { - ...mapActions('diffs', [ - 'setInlineDiffViewType', - 'setParallelDiffViewType', - 'toggleShowTreeList', - ]), + ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']), expandAllFiles() { eventHub.$emit(EVT_EXPAND_ALL_FILES); }, @@ -92,7 +88,7 @@ export default { class="gl-mr-3 js-toggle-tree-list" :title="toggleFileBrowserTitle" :selected="showTreeList" - @click="toggleShowTreeList" + @click="setShowTreeList({ showTreeList: !showTreeList })" /> <gl-sprintf v-if="showDropdowns" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index b3cdf138ac9..1a0e65bbb3e 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -447,11 +447,11 @@ export const scrollToFile = ({ state, commit }, path) => { commit(types.VIEW_DIFF_FILE, fileHash); }; -export const toggleShowTreeList = ({ commit, state }, saving = true) => { - commit(types.TOGGLE_SHOW_TREE_LIST); +export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => { + commit(types.SET_SHOW_TREE_LIST, showTreeList); if (saving) { - localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); + localStorage.setItem(MR_TREE_SHOW_KEY, showTreeList); } }; diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 3223d61e48b..ed694419ab1 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -17,7 +17,7 @@ export const RENDER_FILE = 'RENDER_FILE'; export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; -export const TOGGLE_SHOW_TREE_LIST = 'TOGGLE_SHOW_TREE_LIST'; +export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; export const VIEW_DIFF_FILE = 'VIEW_DIFF_FILE'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index c5bb2b40163..7b08c5e75e1 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -247,8 +247,8 @@ export default { [types.TOGGLE_FOLDER_OPEN](state, path) { state.treeEntries[path].opened = !state.treeEntries[path].opened; }, - [types.TOGGLE_SHOW_TREE_LIST](state) { - state.showTreeList = !state.showTreeList; + [types.SET_SHOW_TREE_LIST](state, showTreeList) { + state.showTreeList = showTreeList; }, [types.VIEW_DIFF_FILE](state, fileId) { state.currentDiffFileId = fileId; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index bdcdabe8f78..6e9661ea1a8 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -369,6 +369,9 @@ export default class MergeRequestTabs { projectId: pipelineTableViewEl.dataset.projectId, mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, }, + provide: { + targetProjectFullPath: mrWidgetData?.target_project_full_path || '', + }, }).$mount(); // $mount(el) replaces the el with the new rendered component. We need it in order to mount diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 8b37c94de19..0d1c214c5b1 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div class="col-sm-12 gl-mt-5"> + <div> <gl-alert class="gl-mb-5" :variant="status.variant" diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue index 23808bcb292..23808bcb292 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue index 4929c3206df..4929c3206df 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue index ac0332cb0bd..ac0332cb0bd 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue diff --git a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql index 496036f690f..496036f690f 100644 --- a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 7b8c70ac93e..c1cdb5eb2ee 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,4 +1,5 @@ import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; export const resolvers = { Query: { @@ -11,6 +12,32 @@ export const resolvers = { }; }, }, -}; + Mutation: { + lintCI: (_, { endpoint, content, dry_run }) => { + return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ + valid: data.valid, + errors: data.errors, + warnings: data.warnings, + jobs: data.jobs.map(job => { + const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; -export default resolvers; + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tagList: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + })); + }, + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 59635296de4..a4bdc27d1a0 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -6,8 +6,8 @@ import { redirectTo, mergeUrlParams, refreshCurrentPage } from '~/lib/utils/url_ import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import CommitForm from './components/commit/commit_form.vue'; import TextEditor from './components/text_editor.vue'; -import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; +import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 16ce279a591..b440f300d27 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,35 +1,22 @@ <script> import { escape, capitalize } from 'lodash'; -import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; -import GraphWidthMixin from '../../mixins/graph_width_mixin'; -import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; -import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; +import { MAIN } from './constants'; export default { name: 'PipelineGraph', components: { StageColumnComponent, - GlLoadingIcon, - LinkedPipelinesColumn, }, - mixins: [GraphWidthMixin, GraphBundleMixin], + mixins: [GraphBundleMixin], props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, isLinkedPipeline: { type: Boolean, required: false, default: false, }, - mediator: { + pipeline: { type: Object, required: true, }, @@ -39,60 +26,9 @@ export default { default: MAIN, }, }, - upstream: UPSTREAM, - downstream: DOWNSTREAM, - data() { - return { - downstreamMarginTop: null, - jobName: null, - pipelineExpanded: { - jobName: '', - expanded: false, - }, - }; - }, computed: { graph() { - return this.pipeline.details?.stages; - }, - hasUpstream() { - return ( - this.type !== this.$options.downstream && - this.upstreamPipelines && - this.pipeline.triggered_by !== null - ); - }, - upstreamPipelines() { - return this.pipeline.triggered_by; - }, - hasDownstream() { - return ( - this.type !== this.$options.upstream && - this.downstreamPipelines && - this.pipeline.triggered.length > 0 - ); - }, - downstreamPipelines() { - return this.pipeline.triggered; - }, - expandedUpstream() { - return ( - this.pipeline.triggered_by && - Array.isArray(this.pipeline.triggered_by) && - this.pipeline.triggered_by.find(el => el.isExpanded) - ); - }, - expandedDownstream() { - return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); - }, - pipelineTypeUpstream() { - return this.type !== this.$options.downstream && this.expandedUpstream; - }, - pipelineTypeDownstream() { - return this.type !== this.$options.upstream && this.expandedDownstream; - }, - pipelineProjectId() { - return this.pipeline.project.id; + return this.pipeline.stages; }, }, methods: { @@ -158,22 +94,6 @@ export default { hasUpstreamColumn(index) { return index === 0 && this.hasUpstream; }, - setJob(jobName) { - this.jobName = jobName; - }, - setPipelineExpanded(jobName, expanded) { - if (expanded) { - this.pipelineExpanded = { - jobName, - expanded, - }; - } else { - this.pipelineExpanded = { - expanded, - jobName: '', - }; - } - }, }, }; </script> @@ -183,48 +103,12 @@ export default { class="pipeline-visualization pipeline-graph" :class="{ 'pipeline-tab-content': !isLinkedPipeline }" > - <div - :style="{ - paddingLeft: `${graphLeftPadding}px`, - paddingRight: `${graphRightPadding}px`, - }" - > - <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> - - <pipeline-graph - v-if="pipelineTypeUpstream" - :type="$options.upstream" - class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedUpstream.id}`" - :is-loading="false" - :pipeline="expandedUpstream" - :is-linked-pipeline="true" - :mediator="mediator" - @onClickUpstreamPipeline="clickUpstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - - <linked-pipelines-column - v-if="hasUpstream" - :type="$options.upstream" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" - /> - - <ul - v-if="!isLoading" - :class="{ - 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, - }" - class="stage-column-list align-top" - > + <div> + <ul class="stage-column-list align-top"> <stage-column-component v-for="(stage, index) in graph" :key="stage.name" :class="{ - 'has-upstream gl-ml-11': hasUpstreamColumn(index), 'has-only-one-job': hasOnlyOneJob(stage), 'gl-mr-26': shouldAddRightMargin(index), }" @@ -232,38 +116,10 @@ export default { :groups="stage.groups" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" - :has-upstream="hasUpstream" :action="stage.status.action" - :job-hovered="jobName" - :pipeline-expanded="pipelineExpanded" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> - - <linked-pipelines-column - v-if="hasDownstream" - :type="$options.downstream" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="handleClickedDownstream" - @downstreamHovered="setJob" - @pipelineExpandToggle="setPipelineExpanded" - /> - - <pipeline-graph - v-if="pipelineTypeDownstream" - :type="$options.downstream" - class="d-inline-block" - :class="`js-downstream-pipeline-${expandedDownstream.id}`" - :is-loading="false" - :pipeline="expandedDownstream" - :is-linked-pipeline="true" - :style="{ 'margin-top': downstreamMarginTop }" - :mediator="mediator" - @onClickDownstreamPipeline="clickDownstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue new file mode 100644 index 00000000000..c1a939af6d3 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -0,0 +1,270 @@ +<script> +import { escape, capitalize } from 'lodash'; +import { GlLoadingIcon } from '@gitlab/ui'; +import StageColumnComponent from './stage_column_component.vue'; +import GraphWidthMixin from '../../mixins/graph_width_mixin'; +import LinkedPipelinesColumn from './linked_pipelines_column.vue'; +import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; +import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; + +export default { + name: 'PipelineGraphLegacy', + components: { + StageColumnComponent, + GlLoadingIcon, + LinkedPipelinesColumn, + }, + mixins: [GraphWidthMixin, GraphBundleMixin], + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + isLinkedPipeline: { + type: Boolean, + required: false, + default: false, + }, + mediator: { + type: Object, + required: true, + }, + type: { + type: String, + required: false, + default: MAIN, + }, + }, + upstream: UPSTREAM, + downstream: DOWNSTREAM, + data() { + return { + downstreamMarginTop: null, + jobName: null, + pipelineExpanded: { + jobName: '', + expanded: false, + }, + }; + }, + computed: { + graph() { + return this.pipeline.details?.stages; + }, + hasUpstream() { + return ( + this.type !== this.$options.downstream && + this.upstreamPipelines && + this.pipeline.triggered_by !== null + ); + }, + upstreamPipelines() { + return this.pipeline.triggered_by; + }, + hasDownstream() { + return ( + this.type !== this.$options.upstream && + this.downstreamPipelines && + this.pipeline.triggered.length > 0 + ); + }, + downstreamPipelines() { + return this.pipeline.triggered; + }, + expandedUpstream() { + return ( + this.pipeline.triggered_by && + Array.isArray(this.pipeline.triggered_by) && + this.pipeline.triggered_by.find(el => el.isExpanded) + ); + }, + expandedDownstream() { + return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); + }, + pipelineTypeUpstream() { + return this.type !== this.$options.downstream && this.expandedUpstream; + }, + pipelineTypeDownstream() { + return this.type !== this.$options.upstream && this.expandedDownstream; + }, + pipelineProjectId() { + return this.pipeline.project.id; + }, + }, + methods: { + capitalizeStageName(name) { + const escapedName = escape(name); + return capitalize(escapedName); + }, + isFirstColumn(index) { + return index === 0; + }, + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (this.isFirstColumn(index) && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + refreshPipelineGraph() { + this.$emit('refreshPipelineGraph'); + }, + /** + * CSS class is applied: + * - if pipeline graph contains only one stage column component + * + * @param {number} index + * @returns {boolean} + */ + shouldAddRightMargin(index) { + return !(index === this.graph.length - 1); + }, + handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { + /** + * Calculates the margin top of the clicked downstream pipeline by + * subtracting the clicked downstream pipelines offsetTop by it's parent's + * offsetTop and then subtracting 15 + */ + this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); + + /** + * If the expanded trigger is defined and the id is different than the + * pipeline we clicked, then it means we clicked on a sibling downstream link + * and we want to reset the pipeline store. Triggering the reset without + * this condition would mean not allowing downstreams of downstreams to expand + */ + if (this.expandedDownstream?.id !== pipeline.id) { + this.$emit('onResetDownstream', this.pipeline, pipeline); + } + + this.$emit('onClickDownstreamPipeline', pipeline); + }, + calculateMarginTop(downstreamNode, pixelDiff) { + return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; + }, + hasOnlyOneJob(stage) { + return stage.groups.length === 1; + }, + hasUpstreamColumn(index) { + return index === 0 && this.hasUpstream; + }, + setJob(jobName) { + this.jobName = jobName; + }, + setPipelineExpanded(jobName, expanded) { + if (expanded) { + this.pipelineExpanded = { + jobName, + expanded, + }; + } else { + this.pipelineExpanded = { + expanded, + jobName: '', + }; + } + }, + }, +}; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div + class="pipeline-visualization pipeline-graph" + :class="{ 'pipeline-tab-content': !isLinkedPipeline }" + > + <div + :style="{ + paddingLeft: `${graphLeftPadding}px`, + paddingRight: `${graphRightPadding}px`, + }" + > + <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> + + <pipeline-graph-legacy + v-if="pipelineTypeUpstream" + :type="$options.upstream" + class="d-inline-block upstream-pipeline" + :class="`js-upstream-pipeline-${expandedUpstream.id}`" + :is-loading="false" + :pipeline="expandedUpstream" + :is-linked-pipeline="true" + :mediator="mediator" + @onClickUpstreamPipeline="clickUpstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + + <linked-pipelines-column + v-if="hasUpstream" + :type="$options.upstream" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" + /> + + <ul + v-if="!isLoading" + :class="{ + 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, + }" + class="stage-column-list align-top" + > + <stage-column-component + v-for="(stage, index) in graph" + :key="stage.name" + :class="{ + 'has-upstream gl-ml-11': hasUpstreamColumn(index), + 'has-only-one-job': hasOnlyOneJob(stage), + 'gl-mr-26': shouldAddRightMargin(index), + }" + :title="capitalizeStageName(stage.name)" + :groups="stage.groups" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)" + :has-upstream="hasUpstream" + :action="stage.status.action" + :job-hovered="jobName" + :pipeline-expanded="pipelineExpanded" + @refreshPipelineGraph="refreshPipelineGraph" + /> + </ul> + + <linked-pipelines-column + v-if="hasDownstream" + :type="$options.downstream" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" + @pipelineExpandToggle="setPipelineExpanded" + /> + + <pipeline-graph-legacy + v-if="pipelineTypeDownstream" + :type="$options.downstream" + class="d-inline-block" + :class="`js-downstream-pipeline-${expandedDownstream.id}`" + :is-loading="false" + :pipeline="expandedDownstream" + :is-linked-pipeline="true" + :style="{ 'margin-top': downstreamMarginTop }" + :mediator="mediator" + @onClickDownstreamPipeline="clickDownstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index a75ec585b95..258b6bf6b6d 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -64,7 +64,7 @@ export default { </script> <template> <li :class="stageConnectorClass" class="stage-column"> - <div class="stage-name position-relative"> + <div class="stage-name position-relative" data-testid="stage-column-title"> {{ title }} <action-component v-if="hasAction" diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js new file mode 100644 index 00000000000..698bade19fe --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -0,0 +1,45 @@ +const unwrapPipelineData = (mainPipelineId, data) => { + if (!data?.project?.pipeline) { + return null; + } + + const { + id, + upstream, + downstream, + stages: { nodes: stages }, + } = data.project.pipeline; + + const unwrappedNestedGroups = stages.map(stage => { + const { + groups: { nodes: groups }, + } = stage; + return { ...stage, groups }; + }); + + const nodes = unwrappedNestedGroups.map(({ name, status, groups }) => { + const groupsWithJobs = groups.map(group => { + const jobs = group.jobs.nodes.map(job => { + const { needs } = job; + return { ...job, needs: needs.nodes.map(need => need.name) }; + }); + + return { ...group, jobs }; + }); + + return { name, status, groups: groupsWithJobs }; + }); + + const addMulti = pipeline => { + return { ...pipeline, multiproject: mainPipelineId !== pipeline.id }; + }; + + return { + id, + stages: nodes, + upstream: upstream ? [upstream].map(addMulti) : [], + downstream: downstream ? downstream.map(addMulti) : [], + }; +}; + +export { unwrapPipelineData }; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 63262cc79fd..bde0dd53aac 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -25,6 +25,11 @@ export default { required: true, }, }, + inject: { + targetProjectFullPath: { + default: '', + }, + }, computed: { user() { return this.pipeline.user; @@ -32,6 +37,12 @@ export default { isScheduled() { return this.pipeline.source === SCHEDULE_ORIGIN; }, + isInFork() { + return Boolean( + this.targetProjectFullPath && + this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, + ); + }, }, }; </script> @@ -52,9 +63,8 @@ export default { :title="__('This pipeline was triggered by a schedule.')" class="badge badge-info" data-testid="pipeline-url-scheduled" + >{{ __('Scheduled') }}</span > - {{ __('Scheduled') }} - </span> </gl-link> <span v-if="pipeline.flags.latest" @@ -62,27 +72,24 @@ export default { :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" data-testid="pipeline-url-latest" + >{{ __('latest') }}</span > - {{ __('latest') }} - </span> <span v-if="pipeline.flags.yaml_errors" v-gl-tooltip :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" data-testid="pipeline-url-yaml" + >{{ __('yaml invalid') }}</span > - {{ __('yaml invalid') }} - </span> <span v-if="pipeline.flags.failure_reason" v-gl-tooltip :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" data-testid="pipeline-url-failure" + >{{ __('error') }}</span > - {{ __('error') }} - </span> <gl-link v-if="pipeline.flags.auto_devops" :id="`pipeline-url-autodevops-${pipeline.id}`" @@ -112,17 +119,16 @@ export default { </gl-sprintf> </div> </template> - <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow"> - {{ __('Learn more about Auto DevOps') }} - </gl-link> + <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{ + __('Learn more about Auto DevOps') + }}</gl-link> </gl-popover> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning" data-testid="pipeline-url-stuck" + >{{ __('stuck') }}</span > - {{ __('stuck') }} - </span> <span v-if="pipeline.flags.detached_merge_request_pipeline" v-gl-tooltip @@ -133,9 +139,16 @@ export default { " class="js-pipeline-url-detached badge badge-info" data-testid="pipeline-url-detached" + >{{ __('detached') }}</span + > + <span + v-if="isInFork" + v-gl-tooltip + :title="__('Pipeline ran in fork of project')" + class="badge badge-info" + data-testid="pipeline-url-fork" + >{{ __('fork') }}</span > - {{ __('detached') }} - </span> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 29dec2309a7..313bb6dfa9d 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; -import pipelineGraph from './components/graph/graph_component.vue'; +import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import legacyPipelineHeader from './components/legacy_header_component.vue'; @@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => { new Vue({ el: SELECTORS.PIPELINE_GRAPH, components: { - pipelineGraph, + PipelineGraphLegacy, }, mixins: [GraphBundleMixin], data() { @@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => { }; }, render(createElement) { - return createElement('pipeline-graph', { + return createElement('pipeline-graph-legacy', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 3492f19c996..f751df6367e 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -23,7 +23,8 @@ export default class SingleFileDiff { this.file = file; this.toggleDiff = this.toggleDiff.bind(this); this.content = $('.diff-content', this.file); - this.$toggleIcon = $('.diff-toggle-caret', this.file); + this.$chevronRightIcon = $('.diff-toggle-caret .chevron-right', this.file); + this.$chevronDownIcon = $('.diff-toggle-caret .chevron-down', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diffForPath'); this.isOpen = !this.diffForPath; if (this.diffForPath) { @@ -34,13 +35,13 @@ export default class SingleFileDiff { .hide(); this.content = null; this.collapsedContent.after(this.loadingContent); - this.$toggleIcon.addClass('fa-caret-right'); + this.$chevronRightIcon.removeClass('gl-display-none'); } else { this.collapsedContent = $(WRAPPER) .html(COLLAPSED_HTML) .hide(); this.content.after(this.collapsedContent); - this.$toggleIcon.addClass('fa-caret-down'); + this.$chevronDownIcon.removeClass('gl-display-none'); } $('.js-file-title, .click-to-expand', this.file).on('click', e => { @@ -52,20 +53,23 @@ export default class SingleFileDiff { if ( !$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && - !$target.hasClass('diff-toggle-caret') + !$target.closest('.diff-toggle-caret').length > 0 ) return; this.isOpen = !this.isOpen; if (!this.isOpen && !this.hasError) { this.content.hide(); - this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down'); + this.$chevronRightIcon.removeClass('gl-display-none'); + this.$chevronDownIcon.addClass('gl-display-none'); this.collapsedContent.show(); } else if (this.content) { this.collapsedContent.hide(); this.content.show(); - this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); + this.$chevronDownIcon.removeClass('gl-display-none'); + this.$chevronRightIcon.addClass('gl-display-none'); } else { - this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); + this.$chevronDownIcon.removeClass('gl-display-none'); + this.$chevronRightIcon.addClass('gl-display-none'); return this.getContentHTML(cb); } } diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 8666b0f9576..161d59fdd85 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -386,6 +386,7 @@ class ProjectsController < Projects::ApplicationController wiki_access_level pages_access_level metrics_dashboard_access_level + operations_access_level ] end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 68d61b1532f..539e37db1c2 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -8,6 +8,14 @@ module Resolvers argument_class ::Types::BaseArgument + def self.requires_argument! + @requires_argument = true + end + + def self.field_options + super.merge(requires_argument: @requires_argument) + end + def self.singular_type return unless type diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb index e0a68bae397..b60c14ca835 100644 --- a/app/graphql/resolvers/design_management/design_resolver.rb +++ b/app/graphql/resolvers/design_management/design_resolver.rb @@ -5,6 +5,8 @@ module Resolvers class DesignResolver < BaseResolver type ::Types::DesignManagement::DesignType, null: true + requires_argument! + argument :id, ::Types::GlobalIDType[::DesignManagement::Design], required: false, description: 'Find a design by its ID' diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb index 70021057f71..49a4974bfbf 100644 --- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb +++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb @@ -12,6 +12,8 @@ module Resolvers type Types::DesignManagement::DesignAtVersionType, null: true + requires_argument! + authorize :read_design argument :id, DesignAtVersionID, diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb index ecd7ab3ee45..7d20cfc2c8e 100644 --- a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb +++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb @@ -7,6 +7,8 @@ module Resolvers type Types::DesignManagement::VersionType, null: true + requires_argument! + authorize :read_design alias_method :collection, :object diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 5c8aabfe163..4070496b436 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -12,6 +12,7 @@ module Types def initialize(*args, **kwargs, &block) @calls_gitaly = !!kwargs.delete(:calls_gitaly) @constant_complexity = !!kwargs[:complexity] + @requires_argument = !!kwargs.delete(:requires_argument) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) @feature_flag = kwargs[:feature_flag] kwargs = check_feature_flag(kwargs) @@ -20,6 +21,10 @@ module Types super(*args, **kwargs, &block) end + def requires_argument? + @requires_argument || arguments.values.any? { |argument| argument.type.non_null? } + end + # Based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.4/lib/graphql/schema/field.rb#L538-L563 # Modified to fix https://github.com/rmosolgo/graphql-ruby/issues/3113 def resolve_field(obj, args, ctx) diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb index 3451a195c33..4b1f3193136 100644 --- a/app/graphql/types/base_interface.rb +++ b/app/graphql/types/base_interface.rb @@ -3,5 +3,7 @@ module Types module BaseInterface include GraphQL::Schema::Interface + + field_class ::Types::BaseField end end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 9af1f4db425..26fbac15b30 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -2,7 +2,7 @@ module Types module DesignManagement - class DesignCollectionType < BaseObject + class DesignCollectionType < ::Types::BaseObject graphql_name 'DesignCollection' description 'A collection of designs' diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index f25b229d198..87148d6e7a4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -468,6 +468,8 @@ module ProjectsHelper end def can_view_operations_tab?(current_user, project) + return false unless project.feature_available?(:operations, current_user) + [ :metrics_dashboard, :read_alert_management_alert, @@ -622,6 +624,7 @@ module ProjectsHelper lfsEnabled: !!project.lfs_enabled, emailsDisabled: project.emails_disabled?, metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, + operationsAccessLevel: feature.operations_access_level, showDefaultAwardEmojis: project.show_default_award_emojis? } end diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 5feb3b0a1e6..c58a3bab1a9 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -19,5 +19,9 @@ module Clusters with: Gitlab::Regex.cluster_agent_name_regex, message: Gitlab::Regex.cluster_agent_name_regex_message } + + def has_access_to?(requested_project) + requested_project == project + end end end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index b69fb2931c3..10690de1c37 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -70,6 +70,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:metrics_dashboard_access_level, value) end + def operations_access_level=(value) + write_feature_attribute_string(:operations_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/namespace_onboarding_action.rb b/app/models/namespace_onboarding_action.rb index ea0d9d495ae..7c3ab051d93 100644 --- a/app/models/namespace_onboarding_action.rb +++ b/app/models/namespace_onboarding_action.rb @@ -2,4 +2,20 @@ class NamespaceOnboardingAction < ApplicationRecord belongs_to :namespace + + ACTIONS = { + subscription_created: 1 + }.freeze + + enum action: ACTIONS + + class << self + def completed?(namespace, action) + where(namespace: namespace, action: action).exists? + end + + def create_action(namespace, action) + NamespaceOnboardingAction.safe_find_or_create_by(namespace: namespace, action: action) + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index 122395a6d13..96863de6e4c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -388,7 +388,7 @@ class Project < ApplicationRecord :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, - to: :project_feature, allow_nil: true + :operations_enabled?, :operations_access_level, to: :project_feature, allow_nil: true delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?, to: :project_setting, allow_nil: true diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index b3ebcbd4b17..e4cecf70ab4 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -3,7 +3,7 @@ class ProjectFeature < ApplicationRecord include Featurable - FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard operations).freeze set_available_features(FEATURES) @@ -45,6 +45,7 @@ class ProjectFeature < ApplicationRecord default_value_for :wiki_access_level, value: ENABLED, allows_nil: false default_value_for :repository_access_level, value: ENABLED, allows_nil: false default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false + default_value_for :operations_access_level, value: ENABLED, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 231843c5f23..7d0db222eaf 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -185,7 +185,10 @@ class GroupPolicy < BasePolicy rule { developer & developer_maintainer_access }.enable :create_projects rule { create_projects_disabled }.prevent :create_projects - rule { owner | admin }.enable :read_statistics + rule { owner | admin }.policy do + enable :owner_access + enable :read_statistics + end rule { maintainer & can?(:create_projects) }.enable :transfer_projects diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index aa87442cadd..b1d680b4264 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -8,6 +8,7 @@ class NamespacePolicy < BasePolicy condition(:owner) { @subject.owner == @user } rule { owner | admin }.policy do + enable :owner_access enable :create_projects enable :admin_namespace enable :read_namespace diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 13073ed68a1..333bd0345db 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -147,6 +147,7 @@ class ProjectPolicy < BasePolicy builds pages metrics_dashboard + operations ] features.each do |f| @@ -272,6 +273,19 @@ class ProjectPolicy < BasePolicy prevent(:metrics_dashboard) end + rule { operations_disabled }.policy do + prevent(*create_read_update_admin_destroy(:feature_flag)) + prevent(*create_read_update_admin_destroy(:environment)) + prevent(*create_read_update_admin_destroy(:sentry_issue)) + prevent(*create_read_update_admin_destroy(:alert_management_alert)) + prevent(*create_read_update_admin_destroy(:cluster)) + prevent(*create_read_update_admin_destroy(:terraform_state)) + prevent(*create_read_update_admin_destroy(:deployment)) + prevent(:metrics_dashboard) + prevent(:read_pod_logs) + prevent(:read_prometheus) + end + rule { can?(:metrics_dashboard) }.policy do enable :read_prometheus enable :read_deployment diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 8f18a23aa0f..a01db4b498c 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -67,7 +67,7 @@ module Projects @project rescue ActiveRecord::RecordInvalid => e - message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " + message = "Unable to save #{e.inspect}: #{e.record.errors.full_messages.join(", ")}" fail(error: message) rescue => e @project.errors.add(:base, e.message) if @project @@ -122,8 +122,9 @@ module Projects only_concrete_membership: true) if group_access_level > GroupMember::NO_ACCESS - current_user.project_authorizations.create!(project: @project, - access_level: group_access_level) + current_user.project_authorizations.safe_find_or_create_by!( + project: @project, + access_level: group_access_level) end if Feature.enabled?(:specialized_project_authorization_workers) diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index cb43527def1..4a00e0af9d9 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,5 +1,7 @@ - if local_assigns.fetch(:show_toggle, true) - %i.fa.diff-toggle-caret.fa-fw + %span.diff-toggle-caret + = sprite_icon('chevron-right', css_class: 'chevron-right gl-display-none') + = sprite_icon('chevron-down', css_class: 'chevron-down gl-display-none') - if diff_file.submodule? %span diff --git a/changelogs/unreleased/224698-fj-add-operations-setting.yml b/changelogs/unreleased/224698-fj-add-operations-setting.yml new file mode 100644 index 00000000000..663a9478f7a --- /dev/null +++ b/changelogs/unreleased/224698-fj-add-operations-setting.yml @@ -0,0 +1,5 @@ +--- +title: Add Operations project setting logic +merge_request: 48347 +author: +type: added diff --git a/changelogs/unreleased/add-forked-pipeline-indicator.yml b/changelogs/unreleased/add-forked-pipeline-indicator.yml new file mode 100644 index 00000000000..137fcb1c38a --- /dev/null +++ b/changelogs/unreleased/add-forked-pipeline-indicator.yml @@ -0,0 +1,5 @@ +--- +title: Show if a Pipeline was Ran in a Fork +merge_request: 48517 +author: +type: added diff --git a/changelogs/unreleased/authorize_same_project_agent.yml b/changelogs/unreleased/authorize_same_project_agent.yml new file mode 100644 index 00000000000..bb2b4f05c79 --- /dev/null +++ b/changelogs/unreleased/authorize_same_project_agent.yml @@ -0,0 +1,5 @@ +--- +title: Authorize the project for the cluster agent if it is the agent's project +merge_request: 48314 +author: +type: changed diff --git a/changelogs/unreleased/mw-replace-fa-chevron-in-file-header-diff.yml b/changelogs/unreleased/mw-replace-fa-chevron-in-file-header-diff.yml new file mode 100644 index 00000000000..763bcacea68 --- /dev/null +++ b/changelogs/unreleased/mw-replace-fa-chevron-in-file-header-diff.yml @@ -0,0 +1,5 @@ +--- +title: Replace fa icons in single file diff +merge_request: 48136 +author: +type: changed diff --git a/changelogs/unreleased/sh-fix-issue-233862.yml b/changelogs/unreleased/sh-fix-issue-233862.yml new file mode 100644 index 00000000000..081c3574ec4 --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-233862.yml @@ -0,0 +1,5 @@ +--- +title: Fix error 500s creating projects concurrently +merge_request: 48571 +author: +type: fixed diff --git a/config/feature_flags/development/operations.yml b/config/feature_flags/development/operations.yml new file mode 100644 index 00000000000..5b5537ed740 --- /dev/null +++ b/config/feature_flags/development/operations.yml @@ -0,0 +1,8 @@ +--- +name: operations +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48347 +rollout_issue_url: +milestone: '13.7' +type: development +group: group::editor +default_enabled: true diff --git a/db/migrate/20201123081307_add_operations_project_feature_to_metrics.rb b/db/migrate/20201123081307_add_operations_project_feature_to_metrics.rb new file mode 100644 index 00000000000..6801b49fae5 --- /dev/null +++ b/db/migrate/20201123081307_add_operations_project_feature_to_metrics.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddOperationsProjectFeatureToMetrics < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + add_column :project_features, :operations_access_level, :integer, default: 20, null: false + end + end + + def down + with_lock_retries do + remove_column :project_features, :operations_access_level + end + end +end diff --git a/db/schema_migrations/20201123081307 b/db/schema_migrations/20201123081307 new file mode 100644 index 00000000000..5169f80108d --- /dev/null +++ b/db/schema_migrations/20201123081307 @@ -0,0 +1 @@ +9b212f5fd6f58123f0d46249c82b2da49af9bcdd36bcc0de610c4be186b17345
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2559cecd6db..a895fd6414e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15126,7 +15126,8 @@ CREATE TABLE project_features ( pages_access_level integer NOT NULL, forking_access_level integer, metrics_dashboard_access_level integer, - requirements_access_level integer DEFAULT 20 NOT NULL + requirements_access_level integer DEFAULT 20 NOT NULL, + operations_access_level integer DEFAULT 20 NOT NULL ); CREATE SEQUENCE project_features_id_seq diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md index 6b94c94aa36..05f1d34c8e4 100644 --- a/doc/administration/geo/index.md +++ b/doc/administration/geo/index.md @@ -51,6 +51,11 @@ Geo provides: - Authentication system hooks: **Secondary** nodes receives all authentication data (like user accounts and logins) from the **primary** instance. - An intuitive UI: **Secondary** nodes use the same web interface your team has grown accustomed to. In addition, there are visual notifications that block write operations and make it clear that a user is on a **secondary** node. +### Gitaly Cluster + +Geo should not be confused with [Gitaly Cluster](../gitaly/praefect.md). For more information about +the difference between Geo and Gitaly Cluster, see [Gitaly Cluster compared to Geo](../gitaly/praefect.md#gitaly-cluster-compared-to-geo). + ## How it works Your Geo instance can be used for cloning and fetching projects, in addition to reading any data. This will make working with large repositories over large distances much faster. diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index dd0b93b4e84..494d1979784 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -47,18 +47,40 @@ The availability objectives for Gitaly clusters are: [Faster outage detection](https://gitlab.com/gitlab-org/gitaly/-/issues/2608) is planned to improve this to less than 1 second. -The current version supports: +Gitaly Cluster supports: -- Eventual consistency of the secondary replicas. -- Automatic failover from the primary to the secondary. -- Reporting of possible data loss if replication queue is non empty. -- Marking the newly promoted primary read only if possible data loss is - detected. +- [Strong consistency](#strong-consistency) of the secondary replicas. +- [Automatic failover](#automatic-failover-and-leader-election) from the primary to the secondary. +- Reporting of possible data loss if replication queue is non-empty. +- Marking repositories as [read only](#read-only-mode) if data loss is detected to prevent data inconsistencies. Follow the [HA Gitaly epic](https://gitlab.com/groups/gitlab-org/-/epics/1489) for improvements including [horizontally distributing reads](https://gitlab.com/groups/gitlab-org/-/epics/2013). +## Gitaly Cluster compared to Geo + +Gitaly Cluster and [Geo](../geo/index.md) both provide redundancy. However the redundancy of: + +- Gitaly Cluster provides fault tolerance for data storage and is invisible to the user. Users are + not aware when Gitaly Cluster is used. +- Geo provides [replication](../geo/index.md) and [disaster recovery](../geo/disaster_recovery/index.md) for + an entire instance of GitLab. Users know when they are using Geo for + [replication](../geo/index.md). Geo [replicates multiple datatypes](../geo/replication/datatypes.md#limitations-on-replicationverification), + including Git data. + +The following table outlines the major differences between Gitaly Cluster and Geo: + +| Tool | Nodes | Locations | Latency tolerance | Failover | Consistency | Provides redundancy for | +|:---------------|:---------|:----------|:-------------------|:-----------------------------------------------------|:------------------------------|:------------------------| +| Gitaly Cluster | Multiple | Single | Approximately 1 ms | [Automatic](#automatic-failover-and-leader-election) | [Strong](#strong-consistency) | Data storage in Git | +| Geo | Multiple | Multiple | Up to one minute | [Manual](../geo/disaster_recovery/index.md) | Eventual | Entire GitLab instance | + +For more information, see: + +- [Gitaly architecture](index.md#architecture). +- Geo [use cases](../geo/index.md#use-cases) and [architecture](../geo/index.md#architecture). + ## Cluster or shard Gitaly supports multiple models of scaling: @@ -69,8 +91,8 @@ Gitaly supports multiple models of scaling: - Sharding using [repository storage paths](../repository_storage_paths.md), where each repository is stored on the assigned Gitaly node. All requests are routed to this node. -| Cluster | Shard | -|---|---| +| Cluster | Shard | +|:--------------------------------------------------|:----------------------------------------------| | ![Cluster example](img/cluster_example_v13_3.png) | ![Shard example](img/shard_example_v13_3.png) | Generally, Gitaly Cluster can replace sharded configurations, at the expense of additional storage @@ -755,7 +777,7 @@ Big-IP LTM, and Citrix Net Scaler. This documentation outlines what ports and protocols you need configure. | LB Port | Backend Port | Protocol | -|---------|--------------|----------| +|:--------|:-------------|:---------| | 2305 | 2305 | TCP | ### GitLab diff --git a/doc/administration/nfs.md b/doc/administration/nfs.md index e995df1e8a5..e6eef3903bf 100644 --- a/doc/administration/nfs.md +++ b/doc/administration/nfs.md @@ -402,14 +402,19 @@ Additionally, this configuration is specifically warned against in the For supported database architecture, see our documentation about [configuring a database for replication and failover](postgresql/replication_and_failover.md). -<!-- ## Troubleshooting +## Troubleshooting -Include any troubleshooting steps that you can foresee. If you know beforehand what issues -one might have when setting this up, or when something is changed, or on upgrading, it's -important to describe those, too. Think of things that may go wrong and include them here. -This is important to minimize requests for support, and to avoid doc comments with -questions that you know someone might ask. +### Finding the requests that are being made to NFS -Each scenario can be a third-level heading, e.g. `### Getting error message X`. -If you have none to add when creating a doc, leave this section in place -but commented out to help encourage others to add to it in the future. --> +In case of NFS-related problems, it can be helpful to trace +the filesystem requests that are being made by using `perf`: + +```shell +sudo perf trace -e 'nfs4:*' -p $(pgrep -fd ',' puma && pgrep -fd ',' unicorn) +``` + +On Ubuntu 16.04, use: + +```shell +sudo perf trace --no-syscalls --event 'nfs4:*' -p $(pgrep -fd ',' puma && pgrep -fd ',' unicorn) +``` diff --git a/doc/api/project_repository_storage_moves.md b/doc/api/project_repository_storage_moves.md index abd71007031..9bbf6d36416 100644 --- a/doc/api/project_repository_storage_moves.md +++ b/doc/api/project_repository_storage_moves.md @@ -242,7 +242,7 @@ Example response: ## Schedule repository storage moves for all projects on a storage shard -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47142) in GitLab 13.7. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47142) in GitLab 13.7. Schedules repository storage moves for each project repository stored on the source storage shard. diff --git a/doc/ci/pipelines/pipeline_efficiency.md b/doc/ci/pipelines/pipeline_efficiency.md index 8d615a5976d..2693816d8c9 100644 --- a/doc/ci/pipelines/pipeline_efficiency.md +++ b/doc/ci/pipelines/pipeline_efficiency.md @@ -189,8 +189,12 @@ be more efficient, but can also make pipelines harder to understand and analyze. ### Caching -Another optimization method is to use [caching](../caching/index.md) between jobs and stages, -for example [`/node_modules` for NodeJS](../caching/index.md#caching-nodejs-dependencies). +Another optimization method is to [cache](../caching/index.md) dependencies. If your +dependencies change rarely, like [NodeJS `/node_modules`](../caching/index.md#caching-nodejs-dependencies), +caching can make pipeline execution much faster. + +You can use [`cache:when`](../yaml/README.md#cachewhen) to cache downloaded dependencies +even when a job fails. ### Docker Images diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 5f3f3d7682e..60b517eefc6 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -83,6 +83,12 @@ After the data is added to the database or repository and [Elasticsearch is enabled in the Admin Area](#enabling-advanced-search) the search index will be updated automatically. +## Upgrading to a new Elasticsearch major version + +Since Elasticsearch can read and use indices created in the previous major version, you don't need to change anything in GitLab's configuration when upgrading Elasticsearch. + +The only thing worth noting is that if you have created your current index before GitLab 13.0, you might want to [reclaim the production index name](#reclaiming-the-gitlab-production-index-name) or reindex from scratch (which will implicitly create an alias). The latter might be faster depending on the GitLab instance size. Once you do that, you'll be able to perform zero-downtime reindexing and you will benefit from any future features that will make use of the alias. + ## Elasticsearch repository indexer For indexing Git repository data, GitLab uses an [indexer written in Go](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). diff --git a/doc/integration/jenkins.md b/doc/integration/jenkins.md index 13d6c9f7101..1d33eeb8b25 100644 --- a/doc/integration/jenkins.md +++ b/doc/integration/jenkins.md @@ -4,12 +4,9 @@ group: unassigned info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Jenkins CI service +# Jenkins CI service **(CORE)** -NOTE: **Note:** -This documentation focuses only on how to **configure** a Jenkins *integration* with -GitLab. Learn how to **migrate** from Jenkins to GitLab CI/CD in our -[Migrating from Jenkins](../ci/migration/jenkins.md) documentation. +> [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/246756) to Core in GitLab 13.7. From GitLab, you can trigger a Jenkins build when you push code to a repository, or when a merge request is created. In return, the Jenkins pipeline status is shown on merge requests widgets and @@ -32,6 +29,11 @@ Moving from a traditional CI plug-in to a single application for the entire soft life cycle can decrease hours spent on maintaining toolchains by 10% or more. For more details, see the ['GitLab vs. Jenkins' comparison page](https://about.gitlab.com/devops-tools/jenkins-vs-gitlab.html). +NOTE: **Note:** +This documentation focuses only on how to **configure** a Jenkins *integration* with +GitLab. Learn how to **migrate** from Jenkins to GitLab CI/CD in our +[Migrating from Jenkins](../ci/migration/jenkins.md) documentation. + ## Configure GitLab integration with Jenkins GitLab's Jenkins integration requires installation and configuration in both GitLab and Jenkins. diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md index 30b2c766ff6..a6dc353d5c2 100644 --- a/doc/user/clusters/agent/index.md +++ b/doc/user/clusters/agent/index.md @@ -378,9 +378,12 @@ subjects: In a previous step, you configured a `config.yaml` to point to the GitLab projects the Agent should synchronize. In each of those projects, you must create a `manifest.yaml` file for the Agent to monitor. You can auto-generate this `manifest.yaml` with a -templating engine or other means. Only public projects are supported as -manifest projects. Support for private projects is planned in the issue -[Agent authorization for private manifest projects](https://gitlab.com/gitlab-org/gitlab/-/issues/220912). +templating engine or other means. + +The agent is authorized to download manifests for the configuration +project, and public projects. Support for other private projects is +planned in the issue [Agent authorization for private manifest +projects](https://gitlab.com/gitlab-org/gitlab/-/issues/220912). Each time you commit and push a change to this file, the Agent logs the change: diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index d4690709de4..67c6510bf66 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -85,9 +85,7 @@ module API get '/project_info' do project = find_project(params[:id]) - # TODO sort out authorization for real - # https://gitlab.com/gitlab-org/gitlab/-/issues/220912 - unless Ability.allowed?(nil, :download_code, project) + unless Guest.can?(:download_code, project) || agent.has_access_to?(project) not_found! end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9167bcd131f..dda77bec996 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4099,6 +4099,9 @@ msgstr "" msgid "AutoRemediation|%{mrsCount} ready for review" msgstr "" +msgid "AutoRemediation|Auto-fix solution available" +msgstr "" + msgid "AutoRemediation|Auto-fix solutions" msgstr "" @@ -8565,6 +8568,9 @@ msgstr "" msgid "DastProfiles|No profiles created yet" msgstr "" +msgid "DastProfiles|Not Validated" +msgstr "" + msgid "DastProfiles|Passive" msgstr "" @@ -8631,6 +8637,9 @@ msgstr "" msgid "DastProfiles|Username form field" msgstr "" +msgid "DastProfiles|Validated" +msgstr "" + msgid "DastSiteValidation|Copy HTTP header to clipboard" msgstr "" @@ -12193,6 +12202,9 @@ msgstr "" msgid "Found errors in your .gitlab-ci.yml:" msgstr "" +msgid "Framework successfully deleted" +msgstr "" + msgid "Free Trial" msgstr "" @@ -18709,6 +18721,9 @@ msgstr "" msgid "Not found." msgstr "" +msgid "Not permitted to destroy framework" +msgstr "" + msgid "Not ready yet. Try again later." msgstr "" @@ -19006,6 +19021,12 @@ msgstr "" msgid "OnDemandScans|Use existing site profile" msgstr "" +msgid "OnDemandScans|You can either choose a passive scan or validate the target site in your chosen site profile. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}" +msgstr "" + +msgid "OnDemandScans|You cannot run an active scan against an unvalidated site." +msgstr "" + msgid "Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc." msgstr "" @@ -19800,6 +19821,9 @@ msgstr "" msgid "Pipeline minutes quota" msgstr "" +msgid "Pipeline ran in fork of project" +msgstr "" + msgid "Pipeline subscriptions" msgstr "" @@ -28362,6 +28386,9 @@ msgstr "" msgid "Tip:" msgstr "" +msgid "Tip: add a" +msgstr "" + msgid "Title" msgstr "" @@ -32146,6 +32173,9 @@ msgstr "" msgid "for this project" msgstr "" +msgid "fork" +msgstr "" + msgid "fork this project" msgstr "" @@ -33023,6 +33053,9 @@ msgstr "" msgid "time summary" msgstr "" +msgid "to automatically add approvers based on file paths and file types." +msgstr "" + msgid "to help your contributors communicate effectively!" msgstr "" diff --git a/package.json b/package.json index 5de85af2823..dc835a80eab 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.175.0", - "@gitlab/ui": "24.0.0", + "@gitlab/ui": "24.1.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-3", "@rails/ujs": "^6.0.3-2", diff --git a/spec/factories/namespace_onboarding_actions.rb b/spec/factories/namespace_onboarding_actions.rb new file mode 100644 index 00000000000..aca62013b57 --- /dev/null +++ b/spec/factories/namespace_onboarding_actions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :namespace_onboarding_action do + namespace + action { :subscription_created } + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 639fff06cec..413244acaa1 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -32,6 +32,7 @@ FactoryBot.define do visibility_level == Gitlab::VisibilityLevel::PUBLIC ? ProjectFeature::ENABLED : ProjectFeature::PRIVATE end metrics_dashboard_access_level { ProjectFeature::PRIVATE } + operations_access_level { ProjectFeature::ENABLED } # we can't assign the delegated `#ci_cd_settings` attributes directly, as the # `#ci_cd_settings` relation needs to be created first @@ -57,7 +58,8 @@ FactoryBot.define do merge_requests_access_level: merge_requests_access_level, repository_access_level: evaluator.repository_access_level, pages_access_level: evaluator.pages_access_level, - metrics_dashboard_access_level: evaluator.metrics_dashboard_access_level + metrics_dashboard_access_level: evaluator.metrics_dashboard_access_level, + operations_access_level: evaluator.operations_access_level } project.build_project_feature(hash) @@ -322,6 +324,9 @@ FactoryBot.define do trait(:metrics_dashboard_enabled) { metrics_dashboard_access_level { ProjectFeature::ENABLED } } trait(:metrics_dashboard_disabled) { metrics_dashboard_access_level { ProjectFeature::DISABLED } } trait(:metrics_dashboard_private) { metrics_dashboard_access_level { ProjectFeature::PRIVATE } } + trait(:operations_enabled) { operations_access_level { ProjectFeature::ENABLED } } + trait(:operations_disabled) { operations_access_level { ProjectFeature::DISABLED } } + trait(:operations_private) { operations_access_level { ProjectFeature::PRIVATE } } trait :auto_devops do association :auto_devops, factory: :project_auto_devops diff --git a/spec/features/operations_sidebar_link_spec.rb b/spec/features/operations_sidebar_link_spec.rb index 32e2833dafb..798f9092db0 100644 --- a/spec/features/operations_sidebar_link_spec.rb +++ b/spec/features/operations_sidebar_link_spec.rb @@ -2,31 +2,88 @@ require 'spec_helper' -RSpec.describe 'Operations dropdown sidebar' do - let_it_be(:project) { create(:project, :repository) } +RSpec.describe 'Operations dropdown sidebar', :aggregate_failures do + let_it_be_with_reload(:project) { create(:project, :internal, :repository) } let(:user) { create(:user) } + let(:access_level) { ProjectFeature::PUBLIC } + let(:role) { nil } before do - project.add_role(user, role) + project.add_role(user, role) if role + project.project_feature.update_attribute(:operations_access_level, access_level) + sign_in(user) visit project_issues_path(project) end + shared_examples 'shows Operations menu based on the access level' do + context 'when operations project feature is PRIVATE' do + let(:access_level) { ProjectFeature::PRIVATE } + + it 'shows the `Operations` menu' do + expect(page).to have_selector('a.shortcuts-operations', text: 'Operations') + end + end + + context 'when operations project feature is DISABLED' do + let(:access_level) { ProjectFeature::DISABLED } + + it 'does not show the `Operations` menu' do + expect(page).not_to have_selector('a.shortcuts-operations') + end + end + end + + context 'user is not a member' do + it 'has the correct `Operations` menu items', :aggregate_failures do + expect(page).to have_selector('a.shortcuts-operations', text: 'Operations') + expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project)) + expect(page).to have_link(title: 'Environments', href: project_environments_path(project)) + + expect(page).not_to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project)) + expect(page).not_to have_link(title: 'Alerts', href: project_alert_management_index_path(project)) + expect(page).not_to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project)) + expect(page).not_to have_link(title: 'Product Analytics', href: project_product_analytics_path(project)) + expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project)) + expect(page).not_to have_link(title: 'Logs', href: project_logs_path(project)) + expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project)) + end + + context 'when operations project feature is PRIVATE' do + let(:access_level) { ProjectFeature::PRIVATE } + + it 'does not show the `Operations` menu' do + expect(page).not_to have_selector('a.shortcuts-operations') + end + end + + context 'when operations project feature is DISABLED' do + let(:access_level) { ProjectFeature::DISABLED } + + it 'does not show the `Operations` menu' do + expect(page).not_to have_selector('a.shortcuts-operations') + end + end + end + context 'user has guest role' do let(:role) { :guest } it 'has the correct `Operations` menu items' do + expect(page).to have_selector('a.shortcuts-operations', text: 'Operations') expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project)) + expect(page).to have_link(title: 'Environments', href: project_environments_path(project)) expect(page).not_to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project)) expect(page).not_to have_link(title: 'Alerts', href: project_alert_management_index_path(project)) - expect(page).not_to have_link(title: 'Environments', href: project_environments_path(project)) expect(page).not_to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project)) expect(page).not_to have_link(title: 'Product Analytics', href: project_product_analytics_path(project)) expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project)) expect(page).not_to have_link(title: 'Logs', href: project_logs_path(project)) expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project)) end + + it_behaves_like 'shows Operations menu based on the access level' end context 'user has reporter role' do @@ -44,6 +101,8 @@ RSpec.describe 'Operations dropdown sidebar' do expect(page).not_to have_link(title: 'Logs', href: project_logs_path(project)) expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project)) end + + it_behaves_like 'shows Operations menu based on the access level' end context 'user has developer role' do @@ -61,6 +120,8 @@ RSpec.describe 'Operations dropdown sidebar' do expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project)) expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project)) end + + it_behaves_like 'shows Operations menu based on the access level' end context 'user has maintainer role' do @@ -77,5 +138,7 @@ RSpec.describe 'Operations dropdown sidebar' do expect(page).to have_link(title: 'Logs', href: project_logs_path(project)) expect(page).to have_link(title: 'Kubernetes', href: project_clusters_path(project)) end + + it_behaves_like 'shows Operations menu based on the access level' end end diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index b353da5910d..1c99fdb3505 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -3,8 +3,8 @@ import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; import CiLint from '~/ci_lint/components/ci_lint.vue'; -import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; -import lintCIMutation from '~/ci_lint/graphql/mutations/lint_ci.mutation.graphql'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCIMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; import { mockLintDataValid } from '../mock_data'; describe('CI Lint', () => { diff --git a/spec/frontend/ci_lint/graphql/resolvers_spec.js b/spec/frontend/ci_lint/graphql/resolvers_spec.js deleted file mode 100644 index 437c52cf6b4..00000000000 --- a/spec/frontend/ci_lint/graphql/resolvers_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; - -import resolvers from '~/ci_lint/graphql/resolvers'; -import { mockLintResponse } from '../mock_data'; - -describe('~/ci_lint/graphql/resolvers', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('Mutation', () => { - describe('lintCI', () => { - const endpoint = '/ci/lint'; - - beforeEach(() => { - mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); - }); - - it('resolves lint data with type names', async () => { - const result = resolvers.Mutation.lintCI(null, { - endpoint, - content: 'content', - dry_run: true, - }); - - await expect(result).resolves.toMatchSnapshot(); - }); - }); - }); -}); diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js index b87c9f8413b..28ea0f55bf8 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci_lint/mock_data.js @@ -1,86 +1,4 @@ -export const mockLintResponse = { - valid: true, - errors: [], - warnings: [], - jobs: [ - { - name: 'job_1', - stage: 'test', - before_script: ["echo 'before script 1'"], - script: ["echo 'script 1'"], - after_script: ["echo 'after script 1"], - tag_list: ['tag 1'], - environment: 'prd', - when: 'on_success', - allow_failure: false, - only: null, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, - { - name: 'job_2', - stage: 'test', - before_script: ["echo 'before script 2'"], - script: ["echo 'script 2'"], - after_script: ["echo 'after script 2"], - tag_list: ['tag 2'], - environment: 'stg', - when: 'on_success', - allow_failure: true, - only: { refs: ['web', 'chat', 'pushes'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, - ], -}; - -export const mockJobs = [ - { - name: 'job_1', - stage: 'build', - beforeScript: [], - script: ["echo 'Building'"], - afterScript: [], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: true, - only: { refs: ['web', 'chat', 'pushes'] }, - except: null, - }, - { - name: 'multi_project_job', - stage: 'test', - beforeScript: [], - script: [], - afterScript: [], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: false, - only: { refs: ['branches', 'tags'] }, - except: null, - }, - { - name: 'job_2', - stage: 'test', - beforeScript: ["echo 'before script'"], - script: ["echo 'script'"], - afterScript: ["echo 'after script"], - tagList: [], - environment: null, - when: 'on_success', - allowFailure: false, - only: { refs: ['branches@gitlab-org/gitlab'] }, - except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, - }, -]; - -export const mockErrors = [ - '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"', -]; - -export const mockWarnings = [ - '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', -]; +import { mockJobs } from 'jest/pipeline_editor/mock_data'; export const mockLintDataValid = { data: { diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 6f28573c808..7471ef2c1b3 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -9,7 +9,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ menu-class="dropdown-menu-large" > <button - class="btn btn-danger btn-md gl-button split-content-button" + class="btn btn-danger btn-md gl-button split-content-button btn-danger-secondary" type="button" > <!----> @@ -27,7 +27,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <button aria-expanded="false" aria-haspopup="true" - class="btn dropdown-toggle btn-danger btn-md gl-button gl-dropdown-toggle dropdown-toggle-split" + class="btn dropdown-toggle btn-danger btn-md gl-button gl-dropdown-toggle btn-danger-secondary dropdown-toggle-split" type="button" > <span diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 1e94e90c3b0..812836e02b7 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -14,6 +14,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -27,6 +28,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" @@ -52,6 +54,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -65,6 +68,7 @@ exports[`Design management design version dropdown component renders design vers avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 3f7d4bfe9f1..5982b88737c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -638,47 +638,47 @@ describe('diffs/components/app', () => { }); }); - describe('hideTreeListIfJustOneFile', () => { - let toggleShowTreeList; + describe('setTreeDisplay', () => { + let setShowTreeList; beforeEach(() => { - toggleShowTreeList = jest.fn(); + setShowTreeList = jest.fn(); }); afterEach(() => { localStorage.removeItem('mr_tree_show'); }); - it('calls toggleShowTreeList when only 1 file', () => { + it('calls setShowTreeList when only 1 file', () => { createComponent({}, ({ state }) => { state.diffs.diffFiles.push({ sha: '123' }); }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).toHaveBeenCalledWith(false); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList: false, saving: false }); }); - it('does not call toggleShowTreeList when more than 1 file', () => { + it('calls setShowTreeList with true when more than 1 file is in diffs array', () => { createComponent({}, ({ state }) => { state.diffs.diffFiles.push({ sha: '123' }); state.diffs.diffFiles.push({ sha: '124' }); }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).not.toHaveBeenCalled(); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList: true, saving: false }); }); - it('does not call toggleShowTreeList when localStorage is set', () => { + it('calls setShowTreeList with localstorage value', () => { localStorage.setItem('mr_tree_show', 'true'); createComponent({}, ({ state }) => { @@ -686,12 +686,12 @@ describe('diffs/components/app', () => { }); wrapper.setMethods({ - toggleShowTreeList, + setShowTreeList, }); - wrapper.vm.hideTreeListIfJustOneFile(); + wrapper.vm.setTreeDisplay(); - expect(toggleShowTreeList).not.toHaveBeenCalled(); + expect(setShowTreeList).toHaveBeenCalledWith({ showTreeList: true, saving: false }); }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index f010e88a1a7..0032fe926d0 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -32,7 +32,7 @@ import { setHighlightedRow, toggleTreeOpen, scrollToFile, - toggleShowTreeList, + setShowTreeList, renderFileForDiscussionId, setRenderTreeList, setShowWhitespace, @@ -901,15 +901,22 @@ describe('DiffsStoreActions', () => { }); }); - describe('toggleShowTreeList', () => { + describe('setShowTreeList', () => { it('commits toggle', done => { - testAction(toggleShowTreeList, null, {}, [{ type: types.TOGGLE_SHOW_TREE_LIST }], [], done); + testAction( + setShowTreeList, + { showTreeList: true }, + {}, + [{ type: types.SET_SHOW_TREE_LIST, payload: true }], + [], + done, + ); }); it('updates localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - toggleShowTreeList({ commit() {}, state: { showTreeList: true } }); + setShowTreeList({ commit() {} }, { showTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); @@ -917,7 +924,7 @@ describe('DiffsStoreActions', () => { it('does not update localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - toggleShowTreeList({ commit() {}, state: { showTreeList: true } }, false); + setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); expect(localStorage.setItem).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index ae23cea157d..2dda9a0ad71 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -621,15 +621,11 @@ describe('DiffsStoreMutations', () => { }); }); - describe('TOGGLE_SHOW_TREE_LIST', () => { - it('toggles showTreeList', () => { + describe('SET_SHOW_TREE_LIST', () => { + it('sets showTreeList', () => { const state = createState(); - mutations[types.TOGGLE_SHOW_TREE_LIST](state); - - expect(state.showTreeList).toBe(false, 'Failed to toggle showTreeList to false'); - - mutations[types.TOGGLE_SHOW_TREE_LIST](state); + mutations[types.SET_SHOW_TREE_LIST](state, true); expect(state.showTreeList).toBe(true, 'Failed to toggle showTreeList to true'); }); diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap index e4620590e62..f9845c813e2 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -60,6 +60,7 @@ exports[`Alert integration settings form default state should match the default data-qa-selector="incident_templates_item" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index c40b7c90c72..751ceb3c235 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -86,7 +86,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <button aria-expanded="false" aria-haspopup="true" - class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle" + class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary" type="button" > <!----> @@ -201,7 +201,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <button aria-expanded="false" aria-haspopup="true" - class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle" + class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary" type="button" > <!----> diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 8ccad7d5c22..30cc87242d0 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -20,6 +20,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -34,6 +35,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" @@ -47,6 +49,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/ci_lint/components/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index 93c2d2dbcf3..e9c6ed60860 100644 --- a/spec/frontend/ci_lint/components/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -1,8 +1,8 @@ import { shallowMount, mount } from '@vue/test-utils'; import { GlTable, GlLink } from '@gitlab/ui'; -import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { mockJobs, mockErrors, mockWarnings } from '../mock_data'; +import { mockJobs, mockErrors, mockWarnings } from '../../mock_data'; describe('CI Lint Results', () => { let wrapper; diff --git a/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js index 6e0a4881e14..b441d26c146 100644 --- a/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlSprintf } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; -import CiLintWarnings from '~/ci_lint/components/ci_lint_warnings.vue'; +import CiLintWarnings from '~/pipeline_editor/components/lint/ci_lint_warnings.vue'; const warnings = ['warning 1', 'warning 2', 'warning 3']; diff --git a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap index 87bec82e350..d7d4d0af90c 100644 --- a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap +++ b/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`~/ci_lint/graphql/resolvers Mutation lintCI resolves lint data with type names 1`] = ` +exports[`~/pipeline_editor/graphql/resolvers Mutation lintCI lint data is as expected 1`] = ` Object { "__typename": "CiLintContent", "errors": Array [], diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index 90acdf3ec0b..b531f8af797 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -1,6 +1,14 @@ +import MockAdapter from 'axios-mock-adapter'; import Api from '~/api'; -import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from '../mock_data'; - +import { + mockCiConfigPath, + mockCiYml, + mockDefaultBranch, + mockLintResponse, + mockProjectPath, +} from '../mock_data'; +import httpStatus from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/pipeline_editor/graphql/resolvers'; jest.mock('~/api', () => { @@ -39,4 +47,43 @@ describe('~/pipeline_editor/graphql/resolvers', () => { }); }); }); + + describe('Mutation', () => { + describe('lintCI', () => { + let mock; + let result; + + const endpoint = '/ci/lint'; + + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); + + result = await resolvers.Mutation.lintCI(null, { + endpoint, + content: 'content', + dry_run: true, + }); + }); + + afterEach(() => { + mock.restore(); + }); + + /* eslint-disable no-underscore-dangle */ + it('lint data has correct type names', async () => { + expect(result.__typename).toBe('CiLintContent'); + + expect(result.jobs[0].__typename).toBe('CiLintJob'); + expect(result.jobs[1].__typename).toBe('CiLintJob'); + + expect(result.jobs[1].only.__typename).toBe('CiLintJobOnlyPolicy'); + }); + /* eslint-enable no-underscore-dangle */ + + it('lint data is as expected', () => { + expect(result).toMatchSnapshot(); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 3073b748e50..8f6ae2b5af7 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -11,3 +11,87 @@ job1: script: - echo 'test' `; + +export const mockLintResponse = { + valid: true, + errors: [], + warnings: [], + jobs: [ + { + name: 'job_1', + stage: 'test', + before_script: ["echo 'before script 1'"], + script: ["echo 'script 1'"], + after_script: ["echo 'after script 1"], + tag_list: ['tag 1'], + environment: 'prd', + when: 'on_success', + allow_failure: false, + only: null, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + { + name: 'job_2', + stage: 'test', + before_script: ["echo 'before script 2'"], + script: ["echo 'script 2'"], + after_script: ["echo 'after script 2"], + tag_list: ['tag 2'], + environment: 'stg', + when: 'on_success', + allow_failure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + ], +}; + +export const mockJobs = [ + { + name: 'job_1', + stage: 'build', + beforeScript: [], + script: ["echo 'Building'"], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: null, + }, + { + name: 'multi_project_job', + stage: 'test', + beforeScript: [], + script: [], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches', 'tags'] }, + except: null, + }, + { + name: 'job_2', + stage: 'test', + beforeScript: ["echo 'before script'"], + script: ["echo 'script'"], + afterScript: ["echo 'after script"], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches@gitlab-org/gitlab'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, +]; + +export const mockErrors = [ + '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"', +]; + +export const mockWarnings = [ + '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', +]; diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js new file mode 100644 index 00000000000..bdfe546f0e4 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js @@ -0,0 +1,305 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; +import PipelineStore from '~/pipelines/stores/pipeline_store'; +import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import graphJSON from './mock_data_legacy'; +import linkedPipelineJSON from './linked_pipelines_mock_data'; +import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; + +describe('graph component', () => { + let store; + let mediator; + let wrapper; + + const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); + const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + const findStageColumns = () => wrapper.findAll(StageColumnComponent); + const findStageColumnAt = i => findStageColumns().at(i); + + beforeEach(() => { + mediator = new PipelinesMediator({ endpoint: '' }); + store = new PipelineStore(); + store.storePipeline(linkedPipelineJSON); + + setHTMLFixture('<div class="layout-page"></div>'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('while is loading', () => { + it('should render a loading icon', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: true, + pipeline: {}, + mediator, + }, + }); + + expect(wrapper.find('.gl-spinner').exists()).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + }); + + it('renders the graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + expect(wrapper.find('.loading-icon').exists()).toBe(false); + expect(wrapper.find('.stage-column-list').exists()).toBe(true); + }); + + it('renders columns in the graph', () => { + expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); + }); + }); + + describe('when linked pipelines are present', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the pipelines graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + }); + + it('should not include the loading icon', () => { + expect(wrapper.find('.fa-spinner').exists()).toBe(false); + }); + + it('should include the stage column', () => { + expect(findStageColumnAt(0).exists()).toBe(true); + }); + + it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { + expect(findStageColumnAt(0).classes()).toEqual( + expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), + ); + }); + + it('should include the left-margin class on the second child', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + + it('should include the left-connector class in the build of the second child', () => { + expect( + findStageColumnAt(1) + .find('.build:nth-child(1)') + .classes('left-connector'), + ).toBe(true); + }); + + it('should include the js-has-linked-pipelines flag', () => { + expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); + }); + }); + + describe('computeds and methods', () => { + describe('capitalizeStageName', () => { + it('it capitalizes the stage name', () => { + expect( + wrapper + .findAll('.stage-column .stage-name') + .at(1) + .text(), + ).toBe('Prebuild'); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns left-margin when there is a triggerer', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('linked pipelines components', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + it('should render an upstream pipelines column at first position', () => { + expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); + expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); + }); + + it('should render a downstream pipelines column at last position', () => { + const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); + + expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); + expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); + }); + + describe('triggered by', () => { + describe('on click', () => { + it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { + const btnWrapper = findExpandPipelineBtn(); + + btnWrapper.trigger('click'); + + btnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ + store.state.pipeline.triggered_by, + ]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered_by[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('triggered', () => { + describe('on click', () => { + it('should emit `onClickTriggered`', () => { + // We have to mock this method since we do both style change and + // emit and event, not mocking returns an error. + wrapper.setMethods({ + handleClickedDownstream: jest.fn(() => + wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), + ), + }); + + const btnWrappers = findAllExpandPipelineBtns(); + const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); + + downstreamBtnWrapper.trigger('click'); + + downstreamBtnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + }); + }); + }); + + describe('when linked pipelines are not present', () => { + beforeEach(() => { + const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the first column with a no margin', () => { + const firstColumn = wrapper.find('.stage-column'); + + expect(firstColumn.classes('no-margin')).toBe(true); + }); + + it('should not render a linked pipelines column', () => { + expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns no-margin when no triggerer and there is one job', () => { + expect(findStageColumnAt(0).classes('no-margin')).toBe(true); + }); + + it('it returns left-margin when no triggerer and not the first stage', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('capitalizeStageName', () => { + it('capitalizes and escapes stage name', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + + expect(findStageColumnAt(1).props('title')).toEqual( + 'Deploy <img src=x onerror=alert(document.domain)>', + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 5a17be1af23..32616c23be9 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,305 +1,53 @@ -import Vue from 'vue'; -import { mount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; -import PipelineStore from '~/pipelines/stores/pipeline_store'; -import graphComponent from '~/pipelines/components/graph/graph_component.vue'; +import { shallowMount } from '@vue/test-utils'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; -import graphJSON from './mock_data'; -import linkedPipelineJSON from './linked_pipelines_mock_data'; -import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; +import { mockPipelineResponse } from './mock_data'; describe('graph component', () => { - let store; - let mediator; let wrapper; - const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); - const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findStageColumns = () => wrapper.findAll(StageColumnComponent); - const findStageColumnAt = i => findStageColumns().at(i); - beforeEach(() => { - mediator = new PipelinesMediator({ endpoint: '' }); - store = new PipelineStore(); - store.storePipeline(linkedPipelineJSON); + const generateResponse = raw => unwrapPipelineData(raw.data.project.pipeline.id, raw.data); - setHTMLFixture('<div class="layout-page"></div>'); - }); + const defaultProps = { + pipeline: generateResponse(mockPipelineResponse), + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; afterEach(() => { wrapper.destroy(); wrapper = null; }); - describe('while is loading', () => { - it('should render a loading icon', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: true, - pipeline: {}, - mediator, - }, - }); - - expect(wrapper.find('.gl-spinner').exists()).toBe(true); - }); - }); - describe('with data', () => { beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); + createComponent(); }); - it('renders the graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - expect(wrapper.find('.loading-icon').exists()).toBe(false); - expect(wrapper.find('.stage-column-list').exists()).toBe(true); - }); - - it('renders columns in the graph', () => { - expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); - }); - }); - - describe('when linked pipelines are present', () => { - beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - describe('rendered output', () => { - it('should include the pipelines graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - }); - - it('should not include the loading icon', () => { - expect(wrapper.find('.fa-spinner').exists()).toBe(false); - }); - - it('should include the stage column', () => { - expect(findStageColumnAt(0).exists()).toBe(true); - }); - - it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { - expect(findStageColumnAt(0).classes()).toEqual( - expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), - ); - }); - - it('should include the left-margin class on the second child', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - - it('should include the left-connector class in the build of the second child', () => { - expect( - findStageColumnAt(1) - .find('.build:nth-child(1)') - .classes('left-connector'), - ).toBe(true); - }); - - it('should include the js-has-linked-pipelines flag', () => { - expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); - }); - }); - - describe('computeds and methods', () => { - describe('capitalizeStageName', () => { - it('it capitalizes the stage name', () => { - expect( - wrapper - .findAll('.stage-column .stage-name') - .at(1) - .text(), - ).toBe('Prebuild'); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns left-margin when there is a triggerer', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); - }); - - describe('linked pipelines components', () => { - beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - it('should render an upstream pipelines column at first position', () => { - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); - }); - - it('should render a downstream pipelines column at last position', () => { - const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); - - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); - }); - - describe('triggered by', () => { - describe('on click', () => { - it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { - const btnWrapper = findExpandPipelineBtn(); - - btnWrapper.trigger('click'); - - btnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ - store.state.pipeline.triggered_by, - ]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered_by[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - }); - }); - - describe('triggered', () => { - describe('on click', () => { - it('should emit `onClickTriggered`', () => { - // We have to mock this method since we do both style change and - // emit and event, not mocking returns an error. - wrapper.setMethods({ - handleClickedDownstream: jest.fn(() => - wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), - ), - }); - - const btnWrappers = findAllExpandPipelineBtns(); - const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); - - downstreamBtnWrapper.trigger('click'); - - downstreamBtnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('when column requests a refresh', () => { - beforeEach(() => { - findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); - }); - - it('refreshPipelineGraph is emitted', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); - }); + it('renders the main columns in the graph', () => { + expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); }); }); describe('when linked pipelines are not present', () => { beforeEach(() => { - const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline, - mediator, - }, - }); + createComponent(); }); - describe('rendered output', () => { - it('should include the first column with a no margin', () => { - const firstColumn = wrapper.find('.stage-column'); - - expect(firstColumn.classes('no-margin')).toBe(true); - }); - - it('should not render a linked pipelines column', () => { - expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns no-margin when no triggerer and there is one job', () => { - expect(findStageColumnAt(0).classes('no-margin')).toBe(true); - }); - - it('it returns left-margin when no triggerer and not the first stage', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); - }); - - describe('capitalizeStageName', () => { - it('capitalizes and escapes stage name', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); - - expect(findStageColumnAt(1).props('title')).toEqual( - 'Deploy <img src=x onerror=alert(document.domain)>', - ); + it('should not render a linked pipelines column', () => { + expect(findLinkedColumns()).toHaveLength(0); }); }); }); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index a4a5d78f906..b468af7ef25 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,261 +1,499 @@ -export default { - id: 123, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - active: false, - coverage: null, - path: '/root/ci-mock/pipelines/123', - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - duration: 9, - finished_at: '2017-04-19T14:30:27.542Z', - stages: [ - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'test', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', +export const mockPipelineResponse = { + data: { + project: { + __typename: 'Project', + pipeline: { + __typename: 'Pipeline', + id: '22', + stages: { + __typename: 'CiStageConnection', + nodes: [ + { + __typename: 'CiStage', + name: 'build', + status: { + __typename: 'DetailedStatus', + action: null, }, - }, - jobs: [ - { - id: 4153, - name: 'test', - build_path: '/root/ci-mock/builds/4153', - retry_path: '/root/ci-mock/builds/4153/retry', - playable: false, - created_at: '2017-04-13T09:25:18.959Z', - updated_at: '2017-04-13T09:25:23.118Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1482', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1482/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#test', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - path: '/root/ci-mock/pipelines/123#test', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', - }, - { - name: 'deploy <img src=x onerror=alert(document.domain)>', - title: 'deploy: passed', - groups: [ - { - name: 'deploy to production', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4166, - name: 'deploy to production', - build_path: '/root/ci-mock/builds/4166', - retry_path: '/root/ci-mock/builds/4166/retry', - playable: false, - created_at: '2017-04-19T14:29:46.463Z', - updated_at: '2017-04-19T14:30:27.498Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_b', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1515', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1515/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - { - name: 'deploy to staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1484', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1484/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'build_d', + size: 3, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 1/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1485', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1485/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1486', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1486/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 3/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1487', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1487/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + ], }, }, - jobs: [ - { - id: 4159, - name: 'deploy to staging', - build_path: '/root/ci-mock/builds/4159', - retry_path: '/root/ci-mock/builds/4159/retry', - playable: false, - created_at: '2017-04-18T16:32:08.420Z', - updated_at: '2017-04-18T16:32:12.631Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiStage', + name: 'test', + status: { + __typename: 'DetailedStatus', + action: null, + }, + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'test_a', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_a', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1514', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1514/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_b', + size: 2, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_b 1/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1489', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1489/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + { + __typename: 'CiJob', + name: 'test_b 2/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1490', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1490/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/pipelines/154', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_d', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_d', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/153', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + }, + ], + }, + }, + ], + }, }, - }, + ], }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#deploy', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + ], }, - path: '/root/ci-mock/pipelines/123#deploy', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'deploy to production', - path: '/root/ci-mock/builds/4166/play', - playable: false, }, - ], - }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: false, - cancelable: false, - }, - ref: { - name: 'master', - path: '/root/ci-mock/tree/master', - tag: false, - branch: true, - }, - commit: { - id: '798e5f902592192afaba73f4668ae30e56eae492', - short_id: '798e5f90', - title: "Merge branch 'new-branch' into 'master'\r", - created_at: '2017-04-13T10:25:17.000+01:00', - parent_ids: [ - '54d483b1ed156fbbf618886ddf7ab023e24f8738', - 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', - ], - message: - "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-04-13T10:25:17.000+01:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-04-13T10:25:17.000+01:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', }, - author_gravatar_url: null, - commit_url: - 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', - commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', }, - created_at: '2017-04-13T09:25:18.881Z', - updated_at: '2017-04-19T14:30:27.561Z', }; diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js new file mode 100644 index 00000000000..a4a5d78f906 --- /dev/null +++ b/spec/frontend/pipelines/graph/mock_data_legacy.js @@ -0,0 +1,261 @@ +export default { + id: 123, + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + active: false, + coverage: null, + path: '/root/ci-mock/pipelines/123', + details: { + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + duration: 9, + finished_at: '2017-04-19T14:30:27.542Z', + stages: [ + { + name: 'test', + title: 'test: passed', + groups: [ + { + name: 'test', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4153, + name: 'test', + build_path: '/root/ci-mock/builds/4153', + retry_path: '/root/ci-mock/builds/4153/retry', + playable: false, + created_at: '2017-04-13T09:25:18.959Z', + updated_at: '2017-04-13T09:25:23.118Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#test', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#test', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', + }, + { + name: 'deploy <img src=x onerror=alert(document.domain)>', + title: 'deploy: passed', + groups: [ + { + name: 'deploy to production', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4166, + name: 'deploy to production', + build_path: '/root/ci-mock/builds/4166', + retry_path: '/root/ci-mock/builds/4166/retry', + playable: false, + created_at: '2017-04-19T14:29:46.463Z', + updated_at: '2017-04-19T14:30:27.498Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'deploy to staging', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4159, + name: 'deploy to staging', + build_path: '/root/ci-mock/builds/4159', + retry_path: '/root/ci-mock/builds/4159/retry', + playable: false, + created_at: '2017-04-18T16:32:08.420Z', + updated_at: '2017-04-18T16:32:12.631Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#deploy', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#deploy', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', + }, + ], + artifacts: [], + manual_actions: [ + { + name: 'deploy to production', + path: '/root/ci-mock/builds/4166/play', + playable: false, + }, + ], + }, + flags: { + latest: true, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: false, + cancelable: false, + }, + ref: { + name: 'master', + path: '/root/ci-mock/tree/master', + tag: false, + branch: true, + }, + commit: { + id: '798e5f902592192afaba73f4668ae30e56eae492', + short_id: '798e5f90', + title: "Merge branch 'new-branch' into 'master'\r", + created_at: '2017-04-13T10:25:17.000+01:00', + parent_ids: [ + '54d483b1ed156fbbf618886ddf7ab023e24f8738', + 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', + ], + message: + "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + author_name: 'Root', + author_email: 'admin@example.com', + authored_date: '2017-04-13T10:25:17.000+01:00', + committer_name: 'Root', + committer_email: 'admin@example.com', + committed_date: '2017-04-13T10:25:17.000+01:00', + author: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + author_gravatar_url: null, + commit_url: + 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + }, + created_at: '2017-04-13T09:25:18.881Z', + updated_at: '2017-04-19T14:30:27.561Z', +}; diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 0bcc3f96f7c..fc45af2c254 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -16,6 +16,7 @@ describe('Pipeline Url Component', () => { const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); + const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]'); const defaultProps = { pipeline: { @@ -30,6 +31,9 @@ describe('Pipeline Url Component', () => { const createComponent = props => { wrapper = shallowMount(PipelineUrlComponent, { propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: 'test/test', + }, }); }; @@ -137,4 +141,15 @@ describe('Pipeline Url Component', () => { expect(findScheduledTag().exists()).toBe(true); expect(findScheduledTag().text()).toContain('Scheduled'); }); + it('should render the fork badge when the pipeline was run in a fork', () => { + createComponent({ + pipeline: { + flags: {}, + project: { fullPath: 'test/forked' }, + }, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index 8eb0e8f9550..9d160eb5ba4 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -14,6 +14,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -33,6 +34,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 3990248d021..623f7d083c5 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -10,6 +10,9 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#null-idClip)" height="130" @@ -226,6 +229,9 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#-idClip)" height="130" diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js index f58647ff12b..2822b1999bc 100644 --- a/spec/frontend/vue_shared/components/toggle_button_spec.js +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -1,101 +1,96 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toggleButton from '~/vue_shared/components/toggle_button.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -describe('Toggle Button', () => { - let vm; - let Component; +describe('Toggle Button component', () => { + let wrapper; - beforeEach(() => { - Component = Vue.extend(toggleButton); - }); + function createComponent(propsData = {}) { + wrapper = shallowMount(ToggleButton, { + propsData, + }); + } + + const findInput = () => wrapper.find('input'); + const findButton = () => wrapper.find('button'); + const findToggleIcon = () => wrapper.find(GlIcon); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - describe('render output', () => { - beforeEach(() => { - vm = mountComponent(Component, { - value: true, - name: 'foo', - }); - }); - - it('renders input with provided name', () => { - expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + it('renders input with provided name', () => { + createComponent({ + name: 'foo', }); - it('renders input with provided value', () => { - expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); - }); - - it('renders input status icon', () => { - expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); - expect(vm.$el.querySelectorAll('svg.s18').length).toEqual(1); - }); + expect(findInput().attributes('name')).toBe('foo'); }); - describe('is-checked', () => { + describe.each` + value | iconName + ${true} | ${'status_success_borderless'} + ${false} | ${'status_failed_borderless'} + `('when `value` prop is `$value`', ({ value, iconName }) => { beforeEach(() => { - vm = mountComponent(Component, { - value: true, + createComponent({ + value, + name: 'foo', }); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); - it('renders is checked class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + it('renders input with correct value attribute', () => { + expect(findInput().attributes('value')).toBe(`${value}`); }); - it('sets aria-label representing toggle state', () => { - vm.value = true; - - expect(vm.ariaLabel).toEqual('Toggle Status: ON'); - - vm.value = false; - - expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + it('renders correct icon', () => { + const icon = findToggleIcon(); + expect(icon.isVisible()).toBe(true); + expect(icon.props('name')).toBe(iconName); + expect(findButton().classes('is-checked')).toBe(value); }); - it('emits change event when clicked', () => { - vm.$el.querySelector('button').click(); + describe('when clicked', () => { + it('emits `change` event with correct event', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).toHaveBeenCalledWith('change', false); + expect(wrapper.emitted('change')).toStrictEqual([[!value]]); + }); }); }); - describe('is-disabled', () => { + describe('when `disabledInput` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, disabledInput: true, }); - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); it('renders disabled button', () => { - expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + expect(findButton().classes()).toContain('is-disabled'); }); - it('does not emit change event when clicked', () => { - vm.$el.querySelector('button').click(); + it('does not emit change event when clicked', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('change')).toBeFalsy(); }); }); - describe('is-loading', () => { + describe('when `isLoading` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, isLoading: true, }); }); it('renders loading class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + expect(findButton().classes()).toContain('is-loading'); }); }); }); diff --git a/spec/graphql/resolvers/concerns/looks_ahead_spec.rb b/spec/graphql/resolvers/concerns/looks_ahead_spec.rb index ebea9e5522b..27ac1572cab 100644 --- a/spec/graphql/resolvers/concerns/looks_ahead_spec.rb +++ b/spec/graphql/resolvers/concerns/looks_ahead_spec.rb @@ -14,7 +14,7 @@ RSpec.describe LooksAhead do # Simplified schema to test lookahead let_it_be(:schema) do - issues_resolver = Class.new(Resolvers::BaseResolver) do + issues_resolver = Class.new(GraphQL::Schema::Resolver) do include LooksAhead def resolve_with_lookahead(**args) @@ -41,7 +41,6 @@ RSpec.describe LooksAhead do field :issues, issue.connection_type, null: true field :issues_with_lookahead, issue.connection_type, - extras: [:lookahead], resolver: issues_resolver, null: true end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 9635a6f9c82..6427d8cd6d0 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -488,6 +488,7 @@ RSpec.describe ProjectsHelper do describe '#can_view_operations_tab?' do before do allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:can?).and_return(false) end subject { helper.send(:can_view_operations_tab?, user, project) } @@ -501,11 +502,19 @@ RSpec.describe ProjectsHelper do :read_cluster ].each do |ability| it 'includes operations tab' do - allow(helper).to receive(:can?).and_return(false) allow(helper).to receive(:can?).with(user, ability, project).and_return(true) is_expected.to be(true) end + + context 'when operations feature is disabled' do + it 'does not include operations tab' do + allow(helper).to receive(:can?).with(user, ability, project).and_return(true) + project.project_feature.update_attribute(:operations_access_level, ProjectFeature::DISABLED) + + is_expected.to be(false) + end + end end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index b33462b4096..194e261c601 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -577,6 +577,7 @@ ProjectFeature: - pages_access_level - metrics_dashboard_access_level - requirements_access_level +- operations_access_level - created_at - updated_at ProtectedBranch::MergeAccessLevel: diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index 148bb3cf870..49f41570717 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -57,4 +57,16 @@ RSpec.describe Clusters::Agent do end end end + + describe '#has_access_to?' do + let(:agent) { build(:cluster_agent) } + + it 'has access to own project' do + expect(agent.has_access_to?(agent.project)).to be_truthy + end + + it 'does not have access to other projects' do + expect(agent.has_access_to?(create(:project))).to be_falsey + end + end end diff --git a/spec/models/concerns/project_features_compatibility_spec.rb b/spec/models/concerns/project_features_compatibility_spec.rb index ba70ff563a8..2059e170446 100644 --- a/spec/models/concerns/project_features_compatibility_spec.rb +++ b/spec/models/concerns/project_features_compatibility_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe ProjectFeaturesCompatibility do let(:project) { create(:project) } let(:features_enabled) { %w(issues wiki builds merge_requests snippets) } - let(:features) { features_enabled + %w(repository pages) } + let(:features) { features_enabled + %w(repository pages operations) } # We had issues_enabled, snippets_enabled, builds_enabled, merge_requests_enabled and issues_enabled fields on projects table # All those fields got moved to a new table called project_feature and are now integers instead of booleans diff --git a/spec/models/namespace_onboarding_action_spec.rb b/spec/models/namespace_onboarding_action_spec.rb index 9dd82c05032..40ff965c134 100644 --- a/spec/models/namespace_onboarding_action_spec.rb +++ b/spec/models/namespace_onboarding_action_spec.rb @@ -3,5 +3,45 @@ require 'spec_helper' RSpec.describe NamespaceOnboardingAction do + let(:namespace) { build(:namespace) } + it { is_expected.to belong_to :namespace } + + describe '.completed?' do + let(:action) { :subscription_created } + + subject { described_class.completed?(namespace, action) } + + context 'action created for the namespace' do + before do + create(:namespace_onboarding_action, namespace: namespace, action: action) + end + + it { is_expected.to eq(true) } + end + + context 'action created for another namespace' do + before do + create(:namespace_onboarding_action, namespace: build(:namespace), action: action) + end + + it { is_expected.to eq(false) } + end + end + + describe '.create_action' do + let(:action) { :subscription_created } + + subject(:create_action) { described_class.create_action(namespace, action) } + + it 'creates the action for the namespace just once' do + expect { create_action }.to change { count_namespace_actions }.by(1) + + expect { create_action }.to change { count_namespace_actions }.by(0) + end + + def count_namespace_actions + described_class.where(namespace: namespace, action: action).count + end + end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index c927c8fd1f9..37402fa04c4 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -40,7 +40,7 @@ RSpec.describe ProjectFeature do end context 'public features' do - features = %w(issues wiki builds merge_requests snippets repository metrics_dashboard) + features = %w(issues wiki builds merge_requests snippets repository metrics_dashboard operations) features.each do |feature| it "does not allow public access level for #{feature}" do diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb index 8f71cf114c3..514d7303ad7 100644 --- a/spec/policies/namespace_policy_spec.rb +++ b/spec/policies/namespace_policy_spec.rb @@ -8,7 +8,7 @@ RSpec.describe NamespacePolicy do let(:admin) { create(:admin) } let(:namespace) { create(:namespace, owner: owner) } - let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects] } + let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects] } subject { described_class.new(current_user, namespace) } diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 6c281030618..305430e38b5 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -944,4 +944,116 @@ RSpec.describe ProjectPolicy do end it_behaves_like 'Self-managed Core resource access tokens' + + describe 'operations feature' do + using RSpec::Parameterized::TableSyntax + + let(:guest_operations_permissions) { [:read_environment, :read_deployment] } + + let(:developer_operations_permissions) do + guest_operations_permissions + [ + :read_feature_flag, :read_sentry_issue, :read_alert_management_alert, :read_terraform_state, + :metrics_dashboard, :read_pod_logs, :read_prometheus, :create_feature_flag, + :create_environment, :create_deployment, :update_feature_flag, :update_environment, + :update_sentry_issue, :update_alert_management_alert, :update_deployment, + :destroy_feature_flag, :destroy_environment, :admin_feature_flag + ] + end + + let(:maintainer_operations_permissions) do + developer_operations_permissions + [ + :read_cluster, :create_cluster, :update_cluster, :admin_environment, + :admin_cluster, :admin_terraform_state, :admin_deployment + ] + end + + where(:project_visibility, :access_level, :role, :allowed) do + :public | ProjectFeature::ENABLED | :maintainer | true + :public | ProjectFeature::ENABLED | :developer | true + :public | ProjectFeature::ENABLED | :guest | true + :public | ProjectFeature::ENABLED | :anonymous | true + :public | ProjectFeature::PRIVATE | :maintainer | true + :public | ProjectFeature::PRIVATE | :developer | true + :public | ProjectFeature::PRIVATE | :guest | true + :public | ProjectFeature::PRIVATE | :anonymous | false + :public | ProjectFeature::DISABLED | :maintainer | false + :public | ProjectFeature::DISABLED | :developer | false + :public | ProjectFeature::DISABLED | :guest | false + :public | ProjectFeature::DISABLED | :anonymous | false + :internal | ProjectFeature::ENABLED | :maintainer | true + :internal | ProjectFeature::ENABLED | :developer | true + :internal | ProjectFeature::ENABLED | :guest | true + :internal | ProjectFeature::ENABLED | :anonymous | false + :internal | ProjectFeature::PRIVATE | :maintainer | true + :internal | ProjectFeature::PRIVATE | :developer | true + :internal | ProjectFeature::PRIVATE | :guest | true + :internal | ProjectFeature::PRIVATE | :anonymous | false + :internal | ProjectFeature::DISABLED | :maintainer | false + :internal | ProjectFeature::DISABLED | :developer | false + :internal | ProjectFeature::DISABLED | :guest | false + :internal | ProjectFeature::DISABLED | :anonymous | false + :private | ProjectFeature::ENABLED | :maintainer | true + :private | ProjectFeature::ENABLED | :developer | true + :private | ProjectFeature::ENABLED | :guest | false + :private | ProjectFeature::ENABLED | :anonymous | false + :private | ProjectFeature::PRIVATE | :maintainer | true + :private | ProjectFeature::PRIVATE | :developer | true + :private | ProjectFeature::PRIVATE | :guest | false + :private | ProjectFeature::PRIVATE | :anonymous | false + :private | ProjectFeature::DISABLED | :maintainer | false + :private | ProjectFeature::DISABLED | :developer | false + :private | ProjectFeature::DISABLED | :guest | false + :private | ProjectFeature::DISABLED | :anonymous | false + end + + with_them do + let(:current_user) { user_subject(role) } + let(:project) { project_subject(project_visibility) } + + it 'allows/disallows the abilities based on the operation feature access level' do + project.project_feature.update!(operations_access_level: access_level) + + if allowed + expect_allowed(*permissions_abilities(role)) + else + expect_disallowed(*permissions_abilities(role)) + end + end + + def project_subject(project_type) + case project_type + when :public + public_project + when :internal + internal_project + else + private_project + end + end + + def user_subject(role) + case role + when :maintainer + maintainer + when :developer + developer + when :guest + guest + when :anonymous + anonymous + end + end + + def permissions_abilities(role) + case role + when :maintainer + maintainer_operations_permissions + when :developer + developer_operations_permissions + else + guest_operations_permissions + end + end + end + end end diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index a532b8e59f2..b082dc400c0 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -137,9 +137,7 @@ RSpec.describe API::Internal::Kubernetes do include_examples 'agent authentication' context 'an agent is found' do - let!(:agent_token) { create(:cluster_agent_token) } - - let(:agent) { agent_token.agent } + let_it_be(:agent_token) { create(:cluster_agent_token) } context 'project is public' do let(:project) { create(:project, :public) } @@ -186,6 +184,16 @@ RSpec.describe API::Internal::Kubernetes do expect(response).to have_gitlab_http_status(:not_found) end + + context 'and agent belongs to project' do + let(:agent_token) { create(:cluster_agent_token, agent: create(:cluster_agent, project: project)) } + + it 'returns 200' do + send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) + + expect(response).to have_gitlab_http_status(:success) + end + end end context 'project is internal' do diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 87c1e63feb6..6a1ed6ce4c6 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -458,8 +458,18 @@ module GraphqlHelpers field_type(field).kind.enum? end + # There are a few non BaseField fields in our schema (pageInfo for one). + # None of them require arguments. def required_arguments?(field) - field.arguments.values.any? { |argument| argument.type.non_null? } + return field.requires_argument? if field.is_a?(::Types::BaseField) + + if (meta = field.try(:metadata)) && meta[:type_class] + required_arguments?(meta[:type_class]) + elsif args = field.try(:arguments) + args.values.any? { |argument| argument.type.non_null? } + else + false + end end def io_value?(value) diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index af46e5474b0..e0e2a18cdd2 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -30,6 +30,7 @@ RSpec.shared_context 'GroupPolicy context' do let(:owner_permissions) do [ + :owner_access, :admin_group, :admin_namespace, :admin_group_member, diff --git a/tooling/lib/tooling/parallel_rspec_runner.rb b/tooling/lib/tooling/parallel_rspec_runner.rb index 443da55a978..d67f69e6114 100644 --- a/tooling/lib/tooling/parallel_rspec_runner.rb +++ b/tooling/lib/tooling/parallel_rspec_runner.rb @@ -11,6 +11,9 @@ require 'knapsack' # # Only the test files allocated by Knapsack and listed in the file # would be executed in the CI node. +# +# Reference: +# https://github.com/ArturT/knapsack/blob/v1.20.0/lib/knapsack/runners/rspec_runner.rb module Tooling class ParallelRSpecRunner def self.run(rspec_args: nil, filter_tests_file: nil) @@ -54,7 +57,7 @@ module Tooling def tests_to_run return node_tests if filter_tests.empty? - node_tests & filter_tests + @tests_to_run ||= node_tests & filter_tests end def node_tests @@ -62,7 +65,8 @@ module Tooling end def filter_tests - filter_tests_file ? tests_from_file(filter_tests_file) : [] + @filter_tests ||= + filter_tests_file ? tests_from_file(filter_tests_file) : [] end def tests_from_file(filter_tests_file) diff --git a/yarn.lock b/yarn.lock index ad4dde41ff8..245586f1dff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -866,10 +866,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.175.0.tgz#734f341784af1cd1d62d160a17bcdfb61ff7b04d" integrity sha512-gXpc87TGSXIzfAr4QER1Qw1v3P47pBO6BXkma52blgwXVmcFNe3nhQzqsqt66wKNzrIrk3lAcB4GUyPHbPVXpg== -"@gitlab/ui@24.0.0": - version "24.0.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-24.0.0.tgz#ec6bdf29bd4797dc6d4de2ff97a9ec4b43cc6308" - integrity sha512-5n5A4hEhFWGzjJ3+0FITL8Pz5o7ry6SQ0VSOko6MwXdgNbUsJaybM0wU/EBl1h8S5LvVlyfji7onpVIcwDxC+Q== +"@gitlab/ui@24.1.0": + version "24.1.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-24.1.0.tgz#931b23c98e239ce6855aea39c68fb1d4941f2997" + integrity sha512-XpyFuz/JlMsOCyxYYvWoXWjTx3xZFhS68z6KkEVE+K7hh5IWPVPQWw5swD6dlZeQO4OfbQXGb7FAqafqZoNoCQ== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |