diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-17 18:13:39 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-17 18:13:39 +0300 |
commit | e303f963d0f03d9e2d21654700b56377701374b0 (patch) | |
tree | c83151e601848d47edf16a5fbd3dcee4d0a21b38 | |
parent | 5d92a0af93588db9c6bef9ab5d81b73daebc782a (diff) |
Add latest changes from gitlab-org/gitlab@master
38 files changed, 757 insertions, 358 deletions
diff --git a/.rubocop_todo/graphql/field_definitions.yml b/.rubocop_todo/graphql/field_definitions.yml index b85b75bb8ca..c6ca8674264 100644 --- a/.rubocop_todo/graphql/field_definitions.yml +++ b/.rubocop_todo/graphql/field_definitions.yml @@ -5,9 +5,6 @@ GraphQL/FieldDefinitions: - app/graphql/types/group_type.rb - app/graphql/types/issue_type.rb - app/graphql/types/label_type.rb - - app/graphql/types/merge_request_type.rb - - app/graphql/types/namespace_type.rb - - app/graphql/types/notes/note_type.rb - app/graphql/types/project_type.rb - app/graphql/types/projects/topic_type.rb - app/graphql/types/release_type.rb diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 4e33a02ca0e..4893803a3b6 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -42,6 +42,11 @@ export default { required: false, default: false, }, + coverageLoaded: { + type: Boolean, + required: false, + default: false, + }, inline: { type: Boolean, required: false, @@ -83,14 +88,15 @@ export default { if (!props.inline || !props.line.left) return {}; return props.fileLineCoverage(props.filePath, props.line.left.new_line); }, - (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'), + (props) => + [props.inline, props.filePath, props.line.left?.new_line, props.coverageLoaded].join(':'), ), coverageStateRight: memoize( (props) => { if (!props.line.right) return {}; return props.fileLineCoverage(props.filePath, props.line.right.new_line); }, - (props) => [props.line.right?.new_line, props.filePath].join(':'), + (props) => [props.line.right?.new_line, props.filePath, props.coverageLoaded].join(':'), ), showCodequalityLeft: memoize( (props) => { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 55c796182ee..8562a1d44e7 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -52,7 +52,7 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), - ...mapState('diffs', ['codequalityDiff', 'highlightedRow']), + ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -180,6 +180,7 @@ export default { :index="index" :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" + :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" @setHighlightedRow="setHighlightedRow" @toggleLineDiscussions=" diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index a5b1a577a78..5f66360a040 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -21,6 +21,7 @@ export default () => ({ startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff diffFiles: [], coverageFiles: {}, + coverageLoaded: false, mergeRequestDiffs: [], mergeRequestDiff: null, diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 4a9df0eafcc..fb35114c0a9 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -86,7 +86,7 @@ export default { }, [types.SET_COVERAGE_DATA](state, coverageFiles) { - Object.assign(state, { coverageFiles }); + Object.assign(state, { coverageFiles, coverageLoaded: true }); }, [types.RENDER_FILE](state, file) { diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index d3c2f4ec39f..962979ba573 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; export const GRAPHQL_PAGE_SIZE = 30; @@ -33,3 +34,66 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`, ); export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); + +/* Table constants */ + +const defaultTableClasses = { + tdClass: 'gl-p-5!', + thClass: DEFAULT_TH_CLASSES, +}; +// eslint-disable-next-line @gitlab/require-i18n-strings +const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; + +export const DEFAULT_FIELDS = [ + { + key: 'status', + label: __('Status'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'job', + label: __('Job'), + ...defaultTableClasses, + columnClass: 'gl-w-20p', + }, + { + key: 'pipeline', + label: __('Pipeline'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'stage', + label: __('Stage'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'name', + label: __('Name'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'duration', + label: __('Duration'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'coverage', + label: __('Coverage'), + tdClass: coverageTdClasses, + thClass: defaultTableClasses.thClass, + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, +]; + +export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index 298c99c4162..f513d2090fa 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,75 +1,17 @@ <script> import { GlTable } from '@gitlab/ui'; -import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; import DurationCell from './cells/duration_cell.vue'; import JobCell from './cells/job_cell.vue'; import PipelineCell from './cells/pipeline_cell.vue'; - -const defaultTableClasses = { - tdClass: 'gl-p-5!', - thClass: DEFAULT_TH_CLASSES, -}; -// eslint-disable-next-line @gitlab/require-i18n-strings -const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; +import { DEFAULT_FIELDS } from './constants'; export default { i18n: { emptyText: s__('Jobs|No jobs to show'), }, - fields: [ - { - key: 'status', - label: __('Status'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'job', - label: __('Job'), - ...defaultTableClasses, - columnClass: 'gl-w-20p', - }, - { - key: 'pipeline', - label: __('Pipeline'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'stage', - label: __('Stage'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'name', - label: __('Name'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'duration', - label: __('Duration'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'coverage', - label: __('Coverage'), - tdClass: coverageTdClasses, - thClass: defaultTableClasses.thClass, - columnClass: 'gl-w-10p', - }, - { - key: 'actions', - label: '', - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - ], components: { ActionsCell, CiBadge, @@ -83,6 +25,11 @@ export default { type: Array, required: true, }, + tableFields: { + type: Array, + required: false, + default: () => DEFAULT_FIELDS, + }, }, methods: { formatCoverage(coverage) { @@ -95,7 +42,7 @@ export default { <template> <gl-table :items="jobs" - :fields="$options.fields" + :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :empty-text="$options.i18n.emptyText" show-empty diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue new file mode 100644 index 00000000000..ffac8206b58 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -0,0 +1,121 @@ +<script> +import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import produce from 'immer'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import eventHub from '~/jobs/components/table/event_hub'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants'; +import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql'; + +export default { + fields: JOBS_TAB_FIELDS, + components: { + GlIntersectionObserver, + GlLoadingIcon, + GlSkeletonLoader, + JobsTable, + }, + inject: { + fullPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + apollo: { + jobs: { + query: getPipelineJobs, + variables() { + return { + ...this.queryVariables, + }; + }, + update(data) { + return data.project?.pipeline?.jobs?.nodes || []; + }, + result({ data }) { + this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; + }, + error() { + createFlash({ message: __('An error occured while fetching the pipelines jobs.') }); + }, + }, + }, + data() { + return { + jobs: [], + jobsPageInfo: {}, + firstLoad: true, + }; + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + iid: this.pipelineIid, + }; + }, + }, + mounted() { + eventHub.$on('jobActionPerformed', this.handleJobAction); + }, + beforeDestroy() { + eventHub.$off('jobActionPerformed', this.handleJobAction); + }, + methods: { + handleJobAction() { + this.firstLoad = true; + + this.$apollo.queries.jobs.refetch(); + }, + fetchMoreJobs() { + this.firstLoad = false; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + ...this.queryVariables, + after: this.jobsPageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, (draftData) => { + draftData.project.pipeline.jobs.nodes = [ + ...previousResult.project.pipeline.jobs.nodes, + ...draftData.project.pipeline.jobs.nodes, + ]; + }); + return results; + }, + }); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> + </div> + + <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" /> + + <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon v-if="$apollo.loading" size="md" /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql new file mode 100644 index 00000000000..5fe47e09d9c --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql @@ -0,0 +1,70 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs(after: $after, first: 20) { + pageInfo { + ...PageInfo + } + nodes { + artifacts { + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index ee9560e36c4..ae8b2503c79 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineJobsApp } from './pipeline_details_jobs'; import { apolloProvider } from './pipeline_shared_client'; import { createTestDetails } from './pipeline_test_details'; @@ -11,6 +12,7 @@ const SELECTORS = { PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TESTS: '#js-pipeline-tests-detail', + PIPELINE_JOBS: '#js-pipeline-jobs-vue', }; export default async function initPipelineDetailsBundle() { @@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading the Test Reports tab.'), }); } + + try { + if (gon.features?.jobsTabVue) { + createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); + } + } catch { + createFlash({ + message: __('An error occurred while loading the Jobs tab.'), + }); + } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js new file mode 100644 index 00000000000..a1294a484f0 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_jobs.js @@ -0,0 +1,34 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import JobsApp from './components/jobs/jobs_app.vue'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineJobsApp = (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath, pipelineIid } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + pipelineIid, + }, + render(createElement) { + return createElement(JobsApp); + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index f5159f22973..549cf64fb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -94,6 +94,20 @@ export default { tertiaryActionsButtons() { return this.tertiaryButtons ? this.tertiaryButtons() : undefined; }, + hydratedSummary() { + const structuredOutput = this.summary(this.collapsedData); + const summary = { + subject: generateText( + typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject, + ), + }; + + if (structuredOutput.meta) { + summary.meta = generateText(structuredOutput.meta); + } + + return summary; + }, }, watch: { isCollapsed(newVal) { @@ -182,7 +196,13 @@ export default { <div class="gl-flex-grow-1"> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> - <div v-else v-safe-html="generateText(summary(collapsedData))"></div> + <div v-else> + <span v-safe-html="hydratedSummary.subject"></span> + <template v-if="hydratedSummary.meta"> + <br /> + <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> + </template> + </div> </div> <actions :widget="$options.label || $options.name" diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index e1e71639115..9584b52eafb 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -388,6 +388,10 @@ const fileExtensionIcons = { log: 'log', }; +const twoFileExtensionIcons = { + 'gradle.kts': 'gradle', +}; + const fileNameIcons = { '.jscsrc': 'json', '.jshintrc': 'json', @@ -598,6 +602,9 @@ const fileNameIcons = { export default function getIconForFile(name) { return ( - fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || '' + fileNameIcons[name] || + twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || + fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || + '' ); } diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 79935300fb6..71dc67bb6dc 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -14,6 +14,10 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] + before_action do + push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml) + end + # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index bd78643eb3d..0672ec6f0f8 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -21,10 +21,8 @@ module Types description: 'Internal ID of the merge request.' field :title, GraphQL::Types::String, null: false, description: 'Title of the merge request.' - markdown_field :title_html, null: true field :description, GraphQL::Types::String, null: true, description: 'Description of the merge request (Markdown rendered as HTML for caching).' - markdown_field :description_html, null: true field :state, MergeRequestStateEnum, null: false, description: 'State of the merge request.' field :created_at, Types::TimeType, null: false, @@ -202,6 +200,9 @@ module Types field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the merge request.' + markdown_field :title_html, null: true + markdown_field :description_html, null: true + def approved_by object.approved_by_users end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 3c5994ac559..ba90fb06cb2 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -20,7 +20,6 @@ module Types field :description, GraphQL::Types::String, null: true, description: 'Description of the namespace.' - markdown_field :description_html, null: true field :visibility, GraphQL::Types::String, null: true, description: 'Visibility of the namespace.' @@ -47,6 +46,8 @@ module Types null: true, description: "Shared runners availability for the namespace and its descendants." + markdown_field :description_html, null: true + def root_storage_statistics Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index da6ea83401d..7314c137010 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -33,8 +33,6 @@ module Types method: :note, description: 'Content of the note.' - markdown_field :body_html, null: true, method: :note - field :created_at, Types::TimeType, null: false, description: 'Timestamp of the note creation.' field :updated_at, Types::TimeType, null: false, @@ -50,6 +48,8 @@ module Types null: true, description: 'URL to view this Note in the Web UI.' + markdown_field :body_html, null: true, method: :note + def url ::Gitlab::UrlBuilder.build(object) end diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index 2a7a5dae291..a689b088854 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -8,7 +8,7 @@ class GravatarService return unless identifier hash = Digest::MD5.hexdigest(identifier.strip.downcase) - size = 40 unless size && size > 0 + size = Groups::GroupMembersHelper::AVATAR_SIZE unless size && size > 0 sprintf gravatar_url, hash: hash, diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 1f9dbca3520..e844a3d4779 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -29,17 +29,20 @@ #js-tab-builds.tab-pane - if stages.present? - .table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th= _('Status') - %th= _('Name') - %th= _('Job ID') - %th - %th= _('Coverage') - %th - = render partial: "projects/stage/stage", collection: stages, as: :stage + - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml) + #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } + - else + .table-holder.pipeline-holder + %table.table.ci-table.pipeline + %thead + %tr + %th= _('Status') + %th= _('Name') + %th= _('Job ID') + %th + %th= _('Coverage') + %th + = render partial: "projects/stage/stage", collection: stages, as: :stage - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page diff --git a/config/feature_flags/development/jobs_tab_vue.yml b/config/feature_flags/development/jobs_tab_vue.yml new file mode 100644 index 00000000000..2958532922a --- /dev/null +++ b/config/feature_flags/development/jobs_tab_vue.yml @@ -0,0 +1,8 @@ +--- +name: jobs_tab_vue +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76146 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347371 +milestone: '14.6' +type: development +group: group::pipeline execution +default_enabled: false diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 62fbf9e7166..3f54f5dd576 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -66,6 +66,24 @@ This solution is relatively simple to set up: you just need to create an email address dedicated to receive your users' replies to GitLab notifications. However, this method only supports replies, and not the other features of [incoming email](#incoming-email). +## Accepted headers + +Email is processed correctly when a configured email address is present in one of the following headers: + +- `To` +- `Delivered-To` +- `Envelope-To` or `X-Envelope-To` + +In GitLab 14.6 and later, [Service Desk](../user/project/service_desk.md) +also checks these additional headers. + +Usually, the "To" field contains the email address of the primary receiver. +However, it might not include the configured GitLab email address if: + +- The address is in the "CC" field. +- The address was included when using "Reply all". +- The email was forwarded. + ## Set it up If you want to use Gmail / Google Apps for incoming email, make sure you have diff --git a/doc/api/group_protected_environments.md b/doc/api/group_protected_environments.md index 6e6b1ffddc3..0e1cd149c51 100644 --- a/doc/api/group_protected_environments.md +++ b/doc/api/group_protected_environments.md @@ -103,7 +103,7 @@ POST /groups/:id/protected_environments | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) maintained by the authenticated user. | | `name` | string | yes | The deployment tier of the protected environment. One of `production`, `staging`, `testing`, `development`, or `other`. Read more about [deployment tiers](../ci/environments/index.md#deployment-tier-of-environments).| -| `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. One of `user_id`, `group_id` or `access_level`. They take the form of `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}` respectively. Here, `group_id` must be of a sub-group of the protecting group.| +| `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. One of `user_id`, `group_id` or `access_level`. They take the form of `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}` respectively. | The assignable `user_id` are the users who belong to the given group with the Maintainer role (or above). The assignable `group_id` are the sub-groups under the given group. diff --git a/doc/development/service_ping/metrics_lifecycle.md b/doc/development/service_ping/metrics_lifecycle.md index 46040146de2..ebfab6341e9 100644 --- a/doc/development/service_ping/metrics_lifecycle.md +++ b/doc/development/service_ping/metrics_lifecycle.md @@ -55,23 +55,24 @@ The correct approach is to add a new metric for GitLab 12.6 release with updated and update existing business analysis artefacts to use `example_metric_without_archived` instead of `example_metric` -## Deprecate a metric +## Remove a metric + +WARNING: +If a metric is not used in Sisense or any other system after 6 months, the +Product Intelligence team marks it as inactive and assigns it to the group owner for review. + +We are working on automating this process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/338466) for details. -If a metric is obsolete and you no longer use it, you can mark it as deprecated. +Product Intelligence removes metrics from Service Ping if they are not used in any Sisense dashboard. -For an example of the metric deprecation process take a look at this [example merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59899) +For an example of the metric removal process, see this [example issue](https://gitlab.com/gitlab-org/gitlab/-/issues/297029). -To deprecate a metric: +To remove a metric: 1. Check the following YAML files and verify the metric is not used in an aggregate: - [`config/metrics/aggregates/*.yaml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/aggregates/) - [`ee/config/metrics/aggregates/*.yaml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/aggregates/) -1. Create an issue in the [GitLab Data Team - project](https://gitlab.com/gitlab-data/analytics/-/issues). Ask for - confirmation that the metric is not used by other teams, or in any of the SiSense - dashboards. - 1. Verify the metric is not used to calculate the conversational index. The conversational index is a measure that reports back to self-managed instances to inform administrators of the progress of DevOps adoption for the instance. @@ -81,70 +82,6 @@ To deprecate a metric: to view the metrics that are used. The metrics are represented as the keys that are passed as a field argument into the `get_value` method. -1. Document the deprecation in the metric's YAML definition. Set - the `status:` attribute to `deprecated`, for example: - - ```yaml - --- - key_path: analytics_unique_visits.analytics_unique_visits_for_any_target_monthly - description: Visits to any of the pages listed above per month - product_section: dev - product_stage: manage - product_group: group::analytics - product_category: - value_type: number - status: deprecated - time_frame: 28d - data_source: - distribution: - - ce - tier: - - free - ``` - -1. Replace the metric's instrumentation with a fixed value. This avoids wasting - resources to calculate the deprecated metric. In - [`lib/gitlab/usage_data.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data.rb) - or - [`ee/lib/ee/gitlab/usage_data.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/ee/gitlab/usage_data.rb), - replace the code that calculates the metric's value with a fixed value that - indicates it's deprecated: - - ```ruby - module Gitlab - class UsageData - DEPRECATED_VALUE = -1000 - - def analytics_unique_visits_data - results['analytics_unique_visits_for_any_target'] = redis_usage_data { unique_visit_service.unique_visits_for(targets: :analytics) } - results['analytics_unique_visits_for_any_target_monthly'] = DEPRECATED_VALUE - - { analytics_unique_visits: results } - end - # ... - end - end - ``` - -## Remove a metric - -### Removal policy - -WARNING: -A metric that is not used in Sisense or any other system after 6 months is marked by the -Product Intelligence team as inactive and is assigned to the group owner for review. - -We are working on automating this process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/338466) for details. - -Metrics can be removed from Service Ping if they: - -- Were previously [deprecated](#deprecate-a-metric). -- Are not used in any Sisense dashboard. - -For an example of the metric removal process take a look at this [example issue](https://gitlab.com/gitlab-org/gitlab/-/issues/297029) - -### To remove a deprecated metric - 1. Verify that removing the metric from the Service Ping payload does not cause errors in [Version App](https://gitlab.com/gitlab-services/version-gitlab-com) when the updated payload is collected and processed. Version App collects @@ -159,9 +96,6 @@ For an example of the metric removal process take a look at this [example issue] Ask for confirmation that the metric is not referred to in any SiSense dashboards and can be safely removed from Service Ping. Use this [example issue](https://gitlab.com/gitlab-data/analytics/-/issues/7539) for guidance. - This step can be skipped if verification done during [deprecation process](#deprecate-a-metric) - reported that metric is not required by any data transformation in Snowflake data warehouse nor it is - used by any of SiSense dashboards. 1. After you verify the metric can be safely removed, update the attributes of the metric's YAML definition: diff --git a/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md b/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md index 4e016bbc166..1f60aafe71b 100644 --- a/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md +++ b/doc/user/project/pages/lets_encrypt_for_gitlab_pages.md @@ -1,165 +1,9 @@ --- -stage: Release -group: Release -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 -description: "How to secure GitLab Pages websites with Let's Encrypt (manual process, deprecated)." +redirect_to: 'custom_domains_ssl_tls_certification/lets_encrypt_integration.md' +remove_date: '2022-03-14' --- -# Let's Encrypt for GitLab Pages (manual process, deprecated) **(FREE)** +This file was moved to [another location](custom_domains_ssl_tls_certification/lets_encrypt_integration.md). -WARNING: -This method is still valid but was **deprecated** in favor of the -[Let's Encrypt integration](custom_domains_ssl_tls_certification/lets_encrypt_integration.md) -introduced in GitLab 12.1. - -If you have a GitLab Pages website served under your own domain, -you might want to secure it with a SSL/TLS certificate. - -[Let's Encrypt](https://letsencrypt.org) is a free, automated, and -open source Certificate Authority. - -## Requirements - -To follow along with this tutorial, we assume you already have: - -- [Created a project](index.md#getting-started) in GitLab - containing your website's source code. -- Acquired a domain (`example.com`) and added a [DNS entry](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain) - pointing it to your Pages website. -- [Added your domain to your Pages project](custom_domains_ssl_tls_certification/index.md#steps) - and verified your ownership. -- Cloned your project into your computer. -- Your website up and running, served under HTTP protocol at `http://example.com`. - -## Obtaining a Let's Encrypt certificate - -Once you have the requirements addressed, follow the instructions -below to learn how to obtain the certificate. - -Note that these instructions were tested on macOS Mojave. For other operating systems the steps -might be slightly different. Follow the -[CertBot instructions](https://certbot.eff.org/) according to your OS. - -1. On your computer, open a terminal and navigate to your repository's - root directory: - - ```shell - cd path/to/dir - ``` - -1. Install CertBot (the tool Let's Encrypt uses to issue certificates): - - ```shell - brew install certbot - ``` - -1. Request a certificate for your domain (`example.com`) and - provide an email account (`your@email.com`) to receive notifications: - - ```shell - sudo certbot certonly -a manual -d example.com --email your@email.com - ``` - - Alternatively, you can register without adding an email account, - but you aren't notified about the certificate expiration's date: - - ```shell - sudo certbot certonly -a manual -d example.com --register-unsafely-without-email - ``` - - NOTE: - Read through CertBot's documentation on their - [command line options](https://eff-certbot.readthedocs.io/using.html#certbot-command-line-options). - -1. You're prompted with a message to agree with their terms. - Press `A` to agree and `Y` to let they log your IP. - - CertBot then prompts you with the following message: - - ```shell - Create a file containing just this data: - - Rxnv6WKo95hsuLVX3osmT6LgmzsJKSaK9htlPToohOP.HUGNKk82jlsmOOfphlt8Jy69iuglsn095nxOMH9j3Yb - - And make it available on your web server at this URL: - - http://example.com/.well-known/acme-challenge/Rxnv6WKo95hsuLVX3osmT6LgmzsJKSaK9htlPToohOP - - Press Enter to Continue - ``` - -1. **Do not press Enter yet.** Let's Encrypt needs to verify your - domain ownership before issuing the certificate. To do so, create 3 - consecutive directories under your website's root: - `/.well-known/acme-challenge/Rxnv6WKo95hsuLVX3osmT6LgmzsJKSaK9htlPToohOP/` - and add to the last folder an `index.html` file containing the content - referred on the previous prompt message: - - ```shell - Rxnv6WKo95hsuLVX3osmT6LgmzsJKSaK9htlPToohOP.HUGNKk82jlsmOOfphlt8Jy69iuglsn095nxOMH9j3Yb - ``` - - Note that this file needs to be accessed under - `http://example.com/.well-known/acme-challenge/Rxnv6WKo95hsuLVX3osmT6LgmzsJKSaK9htlPToohOP` - to allow Let's Encrypt to verify the ownership of your domain, - therefore, it needs to be part of the website content under the - repository's [`public`](index.md#how-it-works) folder. - -1. Add, commit, and push the file into your repository in GitLab. Once the pipeline - passes, press **Enter** on your terminal to continue issuing your - certificate. CertBot then prompts you with the following message: - - ```shell - Waiting for verification... - Cleaning up challenges - - IMPORTANT NOTES: - - Congratulations! Your certificate and chain have been saved at: - /etc/letsencrypt/live/example.com/fullchain.pem - Your key file has been saved at: - /etc/letsencrypt/live/example.com/privkey.pem - Your cert will expire on 2019-03-12. To obtain a new or tweaked - version of this certificate in the future, simply run certbot - again. To non-interactively renew *all* of your certificates, run - "certbot renew" - - If you like Certbot, please consider supporting our work by: - - Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate - Donating to EFF: https://eff.org/donate-le - ``` - -## Add your certificate to GitLab Pages - -Now that your certificate has been issued, let's add it to your Pages site: - -1. Back at GitLab, navigate to your project's **Settings > Pages**, - find your domain and click **Details** and **Edit** to add your certificate. -1. From your terminal, copy and paste the certificate into the first field - **Certificate (PEM)**: - - ```shell - sudo cat /etc/letsencrypt/live/example.com/fullchain.pem | pbcopy - ``` - -1. Copy and paste the private key into the second field **Key (PEM)**: - - ```shell - sudo cat /etc/letsencrypt/live/example.com/privkey.pem | pbcopy - ``` - -1. Click **Save changes** to apply them to your website. -1. Wait a few minutes for the configuration changes to take effect. -1. Visit your website at `https://example.com`. - -To force `https` connections on your site, navigate to your -project's **Settings > Pages** and check **Force HTTPS (requires -valid certificates)**. - -## Renewal - -Let's Encrypt certificates expire every 90 days and you must -renew them periodically. To renew all your certificates at once, run: - -```shell -sudo certbot renew -``` +<!-- This redirect file can be deleted after <2022-03-14>. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 942adce0cbb..072d685bde4 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -289,6 +289,8 @@ In these issues, you can also see our friendly neighborhood [Support Bot](#suppo ### As an end user (issue creator) +> Support for additional email headers [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/346600) in GitLab 14.6. +> In earlier versions, the Service Desk email address had to be in the "To" field. To create a Service Desk issue, an end user does not need to know anything about the GitLab instance. They just send an email to the address they are given, and receive an email back confirming receipt: @@ -304,6 +306,9 @@ are sent as emails: Any responses they send via email are displayed in the issue itself. +For information about headers used for treating email, see +[the incoming email documentation](../../administration/incoming_email.md#accepted-headers). + ### As a responder to the issue For responders to the issue, everything works just like other GitLab issues. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ad5f1ab187e..376b87d8432 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3653,6 +3653,9 @@ msgstr "" msgid "An error in reporting in which a test result incorrectly indicates the presence of a vulnerability in a system when the vulnerability is not present." msgstr "" +msgid "An error occured while fetching the pipelines jobs." +msgstr "" + msgid "An error occurred adding a draft to the thread." msgstr "" @@ -3860,6 +3863,9 @@ msgstr "" msgid "An error occurred while loading projects." msgstr "" +msgid "An error occurred while loading the Jobs tab." +msgstr "" + msgid "An error occurred while loading the Needs tab." msgstr "" @@ -31357,6 +31363,9 @@ msgstr "" msgid "SecurityOrchestration|New policy" msgstr "" +msgid "SecurityOrchestration|No rules defined - policy will not run." +msgstr "" + msgid "SecurityOrchestration|Only owners can update Security Policy Project" msgstr "" @@ -31441,9 +31450,6 @@ msgstr "" msgid "SecurityOrchestration|view results" msgstr "" -msgid "SecurityOrhestration|No rules defined - policy will not run." -msgstr "" - msgid "SecurityPolicies|+%{count} more" msgstr "" diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 074549ff591..55f8fdd78ba 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -26,11 +26,11 @@ RSpec.describe 'mail_room.yml' do before do stub_env('GITLAB_REDIS_QUEUES_CONFIG_FILE', absolute_path(queues_config_path)) - clear_queues_raw_config + redis_clear_raw_config!(Gitlab::Redis::Queues) end after do - clear_queues_raw_config + redis_clear_raw_config!(Gitlab::Redis::Queues) end context 'when incoming email is disabled' do @@ -103,12 +103,6 @@ RSpec.describe 'mail_room.yml' do end end - def clear_queues_raw_config - Gitlab::Redis::Queues.remove_instance_variable(:@_raw_config) - rescue NameError - # raised if @_raw_config was not set; ignore - end - def absolute_path(path) Rails.root.join(path).to_s end diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 2dafaedd262..4378e88f7c1 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -24,14 +24,15 @@ RSpec.describe 'Commits' do end context 'commit status is Generic Commit Status' do - let!(:status) { create(:generic_commit_status, pipeline: pipeline) } + let!(:status) { create(:generic_commit_status, pipeline: pipeline, ref: pipeline.ref) } before do project.add_reporter(user) end - describe 'Commit builds' do + describe 'Commit builds with jobs_tab_feature flag off' do before do + stub_feature_flags(jobs_tab_vue: false) visit pipeline_path(pipeline) end @@ -89,8 +90,9 @@ RSpec.describe 'Commits' do end end - context 'Download artifacts' do + context 'Download artifacts with jobs_tab_vue feature flag off' do before do + stub_feature_flags(jobs_tab_vue: false) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) end @@ -118,8 +120,9 @@ RSpec.describe 'Commits' do end end - context "when logged as reporter" do + context "when logged as reporter and with jobs_tab_vue feature flag off" do before do + stub_feature_flags(jobs_tab_vue: false) project.add_reporter(user) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) visit pipeline_path(pipeline) diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 944cee2a998..6ddc8e43762 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -53,6 +53,7 @@ RSpec.describe 'Pipeline', :js do pipeline: pipeline, name: 'jenkins', stage: 'external', + ref: 'master', target_url: 'http://gitlab.com/status') end end @@ -915,7 +916,7 @@ RSpec.describe 'Pipeline', :js do end end - describe 'GET /:project/-/pipelines/:id/builds' do + describe 'GET /:project/-/pipelines/:id/builds with jobs_tab_vue feature flag turned off' do include_context 'pipeline builds' let_it_be(:project) { create(:project, :repository) } @@ -923,6 +924,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do + stub_feature_flags(jobs_tab_vue: false) visit builds_project_pipeline_path(project, pipeline) end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index e38c4989f26..fb45db213d0 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -625,7 +625,7 @@ RSpec.describe 'Pipelines', :js do create_build('test', 1, 'audit', :created) create_build('deploy', 2, 'production', :created) - create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) + create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master') visit project_pipeline_path(project, pipeline) wait_for_requests diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index c0c92908701..4c5ce429c9d 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -277,3 +277,36 @@ describe('DiffRow', () => { }); }); }); + +describe('coverage state memoization', () => { + it('updates when coverage is loaded', () => { + const lineWithoutCoverage = {}; + const lineWithCoverage = { + text: 'Test coverage: 5 hits', + class: 'coverage', + }; + + const unchangedProps = { + inline: true, + filePath: 'file/path', + line: { left: { new_line: 3 } }, + }; + + const noCoverageProps = { + fileLineCoverage: () => lineWithoutCoverage, + coverageLoaded: false, + ...unchangedProps, + }; + const coverageProps = { + fileLineCoverage: () => lineWithCoverage, + coverageLoaded: true, + ...unchangedProps, + }; + + // this caches no coverage for the line + expect(DiffRow.coverageStateLeft(noCoverageProps)).toStrictEqual(lineWithoutCoverage); + + // this retrieves coverage for the line because it has been recached + expect(DiffRow.coverageStateLeft(coverageProps)).toStrictEqual(lineWithCoverage); + }); +}); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index c104fcd5fb9..d8611b1ce1b 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -112,6 +112,7 @@ describe('DiffsStoreMutations', () => { mutations[types.SET_COVERAGE_DATA](state, coverage); expect(state.coverageFiles).toEqual(coverage); + expect(state.coverageLoaded).toEqual(true); }); }); diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js new file mode 100644 index 00000000000..1ea6096c922 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js @@ -0,0 +1,106 @@ +import { GlIntersectionObserver, GlSkeletonLoader } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql'; +import { mockPipelineJobsQueryResponse } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Jobs app', () => { + let wrapper; + let resolverSpy; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findJobsTable = () => wrapper.findComponent(JobsTable); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[getPipelineJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(JobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + localVue, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the loading state', () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findJobsTable().exists()).toBe(false); + }); + + it('displays the jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('handles job fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occured while fetching the pipelines jobs.', + }); + }); + + it('handles infinite scrolling by calling fetchMore', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + triggerInfiniteScroll(); + + expect(resolverSpy).toHaveBeenCalledWith({ + after: 'eyJpZCI6Ijg0NyJ9', + fullPath: 'root/ci-project', + iid: 1, + }); + }); + + it('does not display main loading state again after fetchMore', async () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + triggerInfiniteScroll(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index a65a1d4f399..b9d20eb7ca5 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -505,3 +505,132 @@ export const mockSearch = [ export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; + +export const mockPipelineJobsQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + __typename: 'Project', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/224', + __typename: 'Pipeline', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + endCursor: 'eyJpZCI6Ijg0NyJ9', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjYyMCJ9', + __typename: 'PageInfo', + }, + nodes: [ + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-620-620', + detailsPath: '/root/ci-project/-/jobs/620', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/620', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'coverage_job', + duration: 4, + finishedAt: '2021-12-06T14:13:49Z', + coverage: 82.71, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-619-619', + detailsPath: '/root/ci-project/-/jobs/619', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/619', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'test_job_two', + duration: 4, + finishedAt: '2021-12-06T14:13:44Z', + coverage: null, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/lib/gitlab/redis/sessions_spec.rb b/spec/lib/gitlab/redis/sessions_spec.rb index a6a0373a4bb..6ecbbf3294d 100644 --- a/spec/lib/gitlab/redis/sessions_spec.rb +++ b/spec/lib/gitlab/redis/sessions_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Redis::Sessions do - include_examples "redis_new_instance_shared_examples", 'sessions', Gitlab::Redis::SharedState + it_behaves_like "redis_new_instance_shared_examples", 'sessions', Gitlab::Redis::SharedState describe 'redis instance used in connection pool' do before do @@ -42,25 +42,51 @@ RSpec.describe Gitlab::Redis::Sessions do end describe '#store' do - subject { described_class.store(namespace: described_class::SESSION_NAMESPACE) } + subject(:store) { described_class.store(namespace: described_class::SESSION_NAMESPACE) } context 'when redis.sessions configuration is NOT provided' do it 'instantiates ::Redis instance' do expect(described_class).to receive(:config_fallback?).and_return(true) - expect(subject).to be_instance_of(::Redis::Store) + expect(store).to be_instance_of(::Redis::Store) end end context 'when redis.sessions configuration is provided' do + let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" } + let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" } + before do + redis_clear_raw_config!(Gitlab::Redis::Sessions) + redis_clear_raw_config!(Gitlab::Redis::SharedState) allow(described_class).to receive(:config_fallback?).and_return(false) end - it 'instantiates an instance of MultiStore' do - expect(subject).to be_instance_of(::Gitlab::Redis::MultiStore) + after do + redis_clear_raw_config!(Gitlab::Redis::Sessions) + redis_clear_raw_config!(Gitlab::Redis::SharedState) + end + + # Check that Gitlab::Redis::Sessions is configured as MultiStore with proper attrs. + it 'instantiates an instance of MultiStore', :aggregate_failures do + expect(described_class).to receive(:config_file_name).and_return(config_new_format_host) + expect(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket) + + expect(store).to be_instance_of(::Gitlab::Redis::MultiStore) + + expect(store.primary_store.to_s).to eq("Redis Client connected to test-host:6379 against DB 99 with namespace session:gitlab") + expect(store.secondary_store.to_s).to eq("Redis Client connected to /path/to/redis.sock against DB 0 with namespace session:gitlab") + + expect(store.instance_name).to eq('Sessions') end - it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_sessions, :use_primary_store_as_default_for_sessions + context 'when MultiStore correctly configured' do + before do + allow(described_class).to receive(:config_file_name).and_return(config_new_format_host) + allow(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket) + end + + it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_sessions, :use_primary_store_as_default_for_sessions + end end end end diff --git a/spec/support/redis/redis_helpers.rb b/spec/support/redis/redis_helpers.rb index f27d873eb31..90c15dea1f8 100644 --- a/spec/support/redis/redis_helpers.rb +++ b/spec/support/redis/redis_helpers.rb @@ -32,4 +32,11 @@ module RedisHelpers def redis_sessions_cleanup! Gitlab::Redis::Sessions.with(&:flushdb) end + + # Usage: reset cached instance config + def redis_clear_raw_config!(instance_class) + instance_class.remove_instance_variable(:@_raw_config) + rescue NameError + # raised if @_raw_config was not set; ignore + end end diff --git a/spec/support/redis/redis_new_instance_shared_examples.rb b/spec/support/redis/redis_new_instance_shared_examples.rb index ede517bfaff..943fe0f11ba 100644 --- a/spec/support/redis/redis_new_instance_shared_examples.rb +++ b/spec/support/redis/redis_new_instance_shared_examples.rb @@ -8,13 +8,13 @@ RSpec.shared_examples "redis_new_instance_shared_examples" do |name, fallback_cl let(:fallback_config_file) { nil } before do - fallback_class.remove_instance_variable(:@_raw_config) rescue nil + redis_clear_raw_config!(fallback_class) allow(fallback_class).to receive(:config_file_name).and_return(fallback_config_file) end after do - fallback_class.remove_instance_variable(:@_raw_config) rescue nil + redis_clear_raw_config!(fallback_class) end it_behaves_like "redis_shared_examples" diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb index 4bee845453d..d4c8682ec71 100644 --- a/spec/support/redis/redis_shared_examples.rb +++ b/spec/support/redis/redis_shared_examples.rb @@ -20,11 +20,11 @@ RSpec.shared_examples "redis_shared_examples" do before do allow(described_class).to receive(:config_file_name).and_return(Rails.root.join(config_file_name).to_s) - clear_raw_config + redis_clear_raw_config!(described_class) end after do - clear_raw_config + redis_clear_raw_config!(described_class) end describe '.config_file_name' do @@ -399,12 +399,6 @@ RSpec.shared_examples "redis_shared_examples" do end end - def clear_raw_config - described_class.remove_instance_variable(:@_raw_config) - rescue NameError - # raised if @_raw_config was not set; ignore - end - def clear_pool described_class.remove_instance_variable(:@pool) rescue NameError |