Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-12-17 15:13:39 +00:00
parent 5d92a0af93
commit e303f963d0
38 changed files with 757 additions and 358 deletions

View file

@ -5,9 +5,6 @@ GraphQL/FieldDefinitions:
- app/graphql/types/group_type.rb - app/graphql/types/group_type.rb
- app/graphql/types/issue_type.rb - app/graphql/types/issue_type.rb
- app/graphql/types/label_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/project_type.rb
- app/graphql/types/projects/topic_type.rb - app/graphql/types/projects/topic_type.rb
- app/graphql/types/release_type.rb - app/graphql/types/release_type.rb

View file

@ -42,6 +42,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
coverageLoaded: {
type: Boolean,
required: false,
default: false,
},
inline: { inline: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -83,14 +88,15 @@ export default {
if (!props.inline || !props.line.left) return {}; if (!props.inline || !props.line.left) return {};
return props.fileLineCoverage(props.filePath, props.line.left.new_line); 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( coverageStateRight: memoize(
(props) => { (props) => {
if (!props.line.right) return {}; if (!props.line.right) return {};
return props.fileLineCoverage(props.filePath, props.line.right.new_line); 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( showCodequalityLeft: memoize(
(props) => { (props) => {

View file

@ -52,7 +52,7 @@ export default {
}, },
computed: { computed: {
...mapGetters('diffs', ['commitId', 'fileLineCoverage']), ...mapGetters('diffs', ['commitId', 'fileLineCoverage']),
...mapState('diffs', ['codequalityDiff', 'highlightedRow']), ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']),
...mapState({ ...mapState({
selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
@ -180,6 +180,7 @@ export default {
:index="index" :index="index"
:is-highlighted="isHighlighted(line)" :is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage" :file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)" @showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow" @setHighlightedRow="setHighlightedRow"
@toggleLineDiscussions=" @toggleLineDiscussions="

View file

@ -21,6 +21,7 @@ export default () => ({
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
diffFiles: [], diffFiles: [],
coverageFiles: {}, coverageFiles: {},
coverageLoaded: false,
mergeRequestDiffs: [], mergeRequestDiffs: [],
mergeRequestDiff: null, mergeRequestDiff: null,
diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType,

View file

@ -86,7 +86,7 @@ export default {
}, },
[types.SET_COVERAGE_DATA](state, coverageFiles) { [types.SET_COVERAGE_DATA](state, coverageFiles) {
Object.assign(state, { coverageFiles }); Object.assign(state, { coverageFiles, coverageLoaded: true });
}, },
[types.RENDER_FILE](state, file) { [types.RENDER_FILE](state, file) {

View file

@ -1,4 +1,5 @@
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
export const GRAPHQL_PAGE_SIZE = 30; 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.`, `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?'); 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');

View file

@ -1,75 +1,17 @@
<script> <script>
import { GlTable } from '@gitlab/ui'; 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 CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import ActionsCell from './cells/actions_cell.vue'; import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue'; import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue'; import JobCell from './cells/job_cell.vue';
import PipelineCell from './cells/pipeline_cell.vue'; import PipelineCell from './cells/pipeline_cell.vue';
import { DEFAULT_FIELDS } from './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 default { export default {
i18n: { i18n: {
emptyText: s__('Jobs|No jobs to show'), 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: { components: {
ActionsCell, ActionsCell,
CiBadge, CiBadge,
@ -83,6 +25,11 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
tableFields: {
type: Array,
required: false,
default: () => DEFAULT_FIELDS,
},
}, },
methods: { methods: {
formatCoverage(coverage) { formatCoverage(coverage) {
@ -95,7 +42,7 @@ export default {
<template> <template>
<gl-table <gl-table
:items="jobs" :items="jobs"
:fields="$options.fields" :fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
:empty-text="$options.i18n.emptyText" :empty-text="$options.i18n.emptyText"
show-empty show-empty

View file

@ -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>

View file

@ -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
}
}
}
}
}
}

View file

@ -3,6 +3,7 @@ import { __ } from '~/locale';
import createDagApp from './pipeline_details_dag'; import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header'; import { createPipelineHeaderApp } from './pipeline_details_header';
import { createPipelineJobsApp } from './pipeline_details_jobs';
import { apolloProvider } from './pipeline_shared_client'; import { apolloProvider } from './pipeline_shared_client';
import { createTestDetails } from './pipeline_test_details'; import { createTestDetails } from './pipeline_test_details';
@ -11,6 +12,7 @@ const SELECTORS = {
PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_HEADER: '#js-pipeline-header-vue',
PIPELINE_TESTS: '#js-pipeline-tests-detail', PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
}; };
export default async function initPipelineDetailsBundle() { export default async function initPipelineDetailsBundle() {
@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() {
message: __('An error occurred while loading the Test Reports tab.'), 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.'),
});
}
} }

View file

@ -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);
},
});
};

View file

@ -94,6 +94,20 @@ export default {
tertiaryActionsButtons() { tertiaryActionsButtons() {
return this.tertiaryButtons ? this.tertiaryButtons() : undefined; 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: { watch: {
isCollapsed(newVal) { isCollapsed(newVal) {
@ -182,7 +196,13 @@ export default {
<div class="gl-flex-grow-1"> <div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<template v-else-if="hasFetchError">{{ widgetErrorText }}</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> </div>
<actions <actions
:widget="$options.label || $options.name" :widget="$options.label || $options.name"

View file

@ -388,6 +388,10 @@ const fileExtensionIcons = {
log: 'log', log: 'log',
}; };
const twoFileExtensionIcons = {
'gradle.kts': 'gradle',
};
const fileNameIcons = { const fileNameIcons = {
'.jscsrc': 'json', '.jscsrc': 'json',
'.jshintrc': 'json', '.jshintrc': 'json',
@ -598,6 +602,9 @@ const fileNameIcons = {
export default function getIconForFile(name) { export default function getIconForFile(name) {
return ( return (
fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || '' fileNameIcons[name] ||
twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] ||
fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] ||
''
); );
} }

View file

@ -14,6 +14,10 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] 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 # 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? } before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }

View file

@ -21,10 +21,8 @@ module Types
description: 'Internal ID of the merge request.' description: 'Internal ID of the merge request.'
field :title, GraphQL::Types::String, null: false, field :title, GraphQL::Types::String, null: false,
description: 'Title of the merge request.' description: 'Title of the merge request.'
markdown_field :title_html, null: true
field :description, GraphQL::Types::String, null: true, field :description, GraphQL::Types::String, null: true,
description: 'Description of the merge request (Markdown rendered as HTML for caching).' description: 'Description of the merge request (Markdown rendered as HTML for caching).'
markdown_field :description_html, null: true
field :state, MergeRequestStateEnum, null: false, field :state, MergeRequestStateEnum, null: false,
description: 'State of the merge request.' description: 'State of the merge request.'
field :created_at, Types::TimeType, null: false, field :created_at, Types::TimeType, null: false,
@ -202,6 +200,9 @@ module Types
field :timelogs, Types::TimelogType.connection_type, null: false, field :timelogs, Types::TimelogType.connection_type, null: false,
description: 'Timelogs on the merge request.' description: 'Timelogs on the merge request.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
def approved_by def approved_by
object.approved_by_users object.approved_by_users
end end

View file

@ -20,7 +20,6 @@ module Types
field :description, GraphQL::Types::String, null: true, field :description, GraphQL::Types::String, null: true,
description: 'Description of the namespace.' description: 'Description of the namespace.'
markdown_field :description_html, null: true
field :visibility, GraphQL::Types::String, null: true, field :visibility, GraphQL::Types::String, null: true,
description: 'Visibility of the namespace.' description: 'Visibility of the namespace.'
@ -47,6 +46,8 @@ module Types
null: true, null: true,
description: "Shared runners availability for the namespace and its descendants." description: "Shared runners availability for the namespace and its descendants."
markdown_field :description_html, null: true
def root_storage_statistics def root_storage_statistics
Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
end end

View file

@ -33,8 +33,6 @@ module Types
method: :note, method: :note,
description: 'Content of the note.' description: 'Content of the note.'
markdown_field :body_html, null: true, method: :note
field :created_at, Types::TimeType, null: false, field :created_at, Types::TimeType, null: false,
description: 'Timestamp of the note creation.' description: 'Timestamp of the note creation.'
field :updated_at, Types::TimeType, null: false, field :updated_at, Types::TimeType, null: false,
@ -50,6 +48,8 @@ module Types
null: true, null: true,
description: 'URL to view this Note in the Web UI.' description: 'URL to view this Note in the Web UI.'
markdown_field :body_html, null: true, method: :note
def url def url
::Gitlab::UrlBuilder.build(object) ::Gitlab::UrlBuilder.build(object)
end end

View file

@ -8,7 +8,7 @@ class GravatarService
return unless identifier return unless identifier
hash = Digest::MD5.hexdigest(identifier.strip.downcase) 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, sprintf gravatar_url,
hash: hash, hash: hash,

View file

@ -29,17 +29,20 @@
#js-tab-builds.tab-pane #js-tab-builds.tab-pane
- if stages.present? - if stages.present?
.table-holder.pipeline-holder - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml)
%table.table.ci-table.pipeline #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
%thead - else
%tr .table-holder.pipeline-holder
%th= _('Status') %table.table.ci-table.pipeline
%th= _('Name') %thead
%th= _('Job ID') %tr
%th %th= _('Status')
%th= _('Coverage') %th= _('Name')
%th %th= _('Job ID')
= render partial: "projects/stage/stage", collection: stages, as: :stage %th
%th= _('Coverage')
%th
= render partial: "projects/stage/stage", collection: stages, as: :stage
- if @pipeline.failed_builds.present? - if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page #js-tab-failures.build-failures.tab-pane.build-page

View file

@ -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

View file

@ -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, 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). 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 ## Set it up
If you want to use Gmail / Google Apps for incoming email, make sure you have If you want to use Gmail / Google Apps for incoming email, make sure you have

View file

@ -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. | | `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).| | `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 `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. The assignable `group_id` are the sub-groups under the given group.

View file

@ -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` and update existing business analysis artefacts to use `example_metric_without_archived` instead of `example_metric`
## Deprecate a metric ## Remove a metric
If a metric is obsolete and you no longer use it, you can mark it as deprecated. 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.
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) We are working on automating this process. See [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/338466) for details.
To deprecate a metric: Product Intelligence removes metrics from Service Ping if they are not used in any Sisense dashboard.
For an example of the metric removal process, see this [example issue](https://gitlab.com/gitlab-org/gitlab/-/issues/297029).
To remove a metric:
1. Check the following YAML files and verify the metric is not used in an aggregate: 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/) - [`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/) - [`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 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 conversational index is a measure that reports back to self-managed instances
to inform administrators of the progress of DevOps adoption for the instance. 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 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. 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 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) errors in [Version App](https://gitlab.com/gitlab-services/version-gitlab-com)
when the updated payload is collected and processed. Version App collects 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 Ask for confirmation that the metric is not referred to in any SiSense dashboards and
can be safely removed from Service Ping. Use this can be safely removed from Service Ping. Use this
[example issue](https://gitlab.com/gitlab-data/analytics/-/issues/7539) for guidance. [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, 1. After you verify the metric can be safely removed,
update the attributes of the metric's YAML definition: update the attributes of the metric's YAML definition:

View file

@ -1,165 +1,9 @@
--- ---
stage: Release redirect_to: 'custom_domains_ssl_tls_certification/lets_encrypt_integration.md'
group: Release remove_date: '2022-03-14'
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)."
--- ---
# 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 redirect file can be deleted after <2022-03-14>. -->
This method is still valid but was **deprecated** in favor of the <!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->
[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
```

View file

@ -289,6 +289,8 @@ In these issues, you can also see our friendly neighborhood [Support Bot](#suppo
### As an end user (issue creator) ### 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 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 the GitLab instance. They just send an email to the address they are given, and
receive an email back confirming receipt: 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. 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 ### As a responder to the issue
For responders to the issue, everything works just like other GitLab issues. For responders to the issue, everything works just like other GitLab issues.

View file

@ -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." 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 "" msgstr ""
msgid "An error occured while fetching the pipelines jobs."
msgstr ""
msgid "An error occurred adding a draft to the thread." msgid "An error occurred adding a draft to the thread."
msgstr "" msgstr ""
@ -3860,6 +3863,9 @@ msgstr ""
msgid "An error occurred while loading projects." msgid "An error occurred while loading projects."
msgstr "" msgstr ""
msgid "An error occurred while loading the Jobs tab."
msgstr ""
msgid "An error occurred while loading the Needs tab." msgid "An error occurred while loading the Needs tab."
msgstr "" msgstr ""
@ -31357,6 +31363,9 @@ msgstr ""
msgid "SecurityOrchestration|New policy" msgid "SecurityOrchestration|New policy"
msgstr "" msgstr ""
msgid "SecurityOrchestration|No rules defined - policy will not run."
msgstr ""
msgid "SecurityOrchestration|Only owners can update Security Policy Project" msgid "SecurityOrchestration|Only owners can update Security Policy Project"
msgstr "" msgstr ""
@ -31441,9 +31450,6 @@ msgstr ""
msgid "SecurityOrchestration|view results" msgid "SecurityOrchestration|view results"
msgstr "" msgstr ""
msgid "SecurityOrhestration|No rules defined - policy will not run."
msgstr ""
msgid "SecurityPolicies|+%{count} more" msgid "SecurityPolicies|+%{count} more"
msgstr "" msgstr ""

View file

@ -26,11 +26,11 @@ RSpec.describe 'mail_room.yml' do
before do before do
stub_env('GITLAB_REDIS_QUEUES_CONFIG_FILE', absolute_path(queues_config_path)) stub_env('GITLAB_REDIS_QUEUES_CONFIG_FILE', absolute_path(queues_config_path))
clear_queues_raw_config redis_clear_raw_config!(Gitlab::Redis::Queues)
end end
after do after do
clear_queues_raw_config redis_clear_raw_config!(Gitlab::Redis::Queues)
end end
context 'when incoming email is disabled' do context 'when incoming email is disabled' do
@ -103,12 +103,6 @@ RSpec.describe 'mail_room.yml' do
end end
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) def absolute_path(path)
Rails.root.join(path).to_s Rails.root.join(path).to_s
end end

View file

@ -24,14 +24,15 @@ RSpec.describe 'Commits' do
end end
context 'commit status is Generic Commit Status' do 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 before do
project.add_reporter(user) project.add_reporter(user)
end end
describe 'Commit builds' do describe 'Commit builds with jobs_tab_feature flag off' do
before do before do
stub_feature_flags(jobs_tab_vue: false)
visit pipeline_path(pipeline) visit pipeline_path(pipeline)
end end
@ -89,8 +90,9 @@ RSpec.describe 'Commits' do
end end
end end
context 'Download artifacts' do context 'Download artifacts with jobs_tab_vue feature flag off' do
before do before do
stub_feature_flags(jobs_tab_vue: false)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build) create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
end end
@ -118,8 +120,9 @@ RSpec.describe 'Commits' do
end end
end end
context "when logged as reporter" do context "when logged as reporter and with jobs_tab_vue feature flag off" do
before do before do
stub_feature_flags(jobs_tab_vue: false)
project.add_reporter(user) project.add_reporter(user)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build) create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
visit pipeline_path(pipeline) visit pipeline_path(pipeline)

View file

@ -53,6 +53,7 @@ RSpec.describe 'Pipeline', :js do
pipeline: pipeline, pipeline: pipeline,
name: 'jenkins', name: 'jenkins',
stage: 'external', stage: 'external',
ref: 'master',
target_url: 'http://gitlab.com/status') target_url: 'http://gitlab.com/status')
end end
end end
@ -915,7 +916,7 @@ RSpec.describe 'Pipeline', :js do
end end
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' include_context 'pipeline builds'
let_it_be(:project) { create(:project, :repository) } 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) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do before do
stub_feature_flags(jobs_tab_vue: false)
visit builds_project_pipeline_path(project, pipeline) visit builds_project_pipeline_path(project, pipeline)
end end

View file

@ -625,7 +625,7 @@ RSpec.describe 'Pipelines', :js do
create_build('test', 1, 'audit', :created) create_build('test', 1, 'audit', :created)
create_build('deploy', 2, 'production', :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) visit project_pipeline_path(project, pipeline)
wait_for_requests wait_for_requests

View file

@ -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);
});
});

View file

@ -112,6 +112,7 @@ describe('DiffsStoreMutations', () => {
mutations[types.SET_COVERAGE_DATA](state, coverage); mutations[types.SET_COVERAGE_DATA](state, coverage);
expect(state.coverageFiles).toEqual(coverage); expect(state.coverageFiles).toEqual(coverage);
expect(state.coverageLoaded).toEqual(true);
}); });
}); });

View file

@ -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);
});
});

View file

@ -505,3 +505,132 @@ export const mockSearch = [
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; 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',
},
],
},
},
},
},
};

View file

@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Gitlab::Redis::Sessions do 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 describe 'redis instance used in connection pool' do
before do before do
@ -42,25 +42,51 @@ RSpec.describe Gitlab::Redis::Sessions do
end end
describe '#store' do 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 context 'when redis.sessions configuration is NOT provided' do
it 'instantiates ::Redis instance' do it 'instantiates ::Redis instance' do
expect(described_class).to receive(:config_fallback?).and_return(true) 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
end end
context 'when redis.sessions configuration is provided' do 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 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) allow(described_class).to receive(:config_fallback?).and_return(false)
end end
it 'instantiates an instance of MultiStore' do after do
expect(subject).to be_instance_of(::Gitlab::Redis::MultiStore) redis_clear_raw_config!(Gitlab::Redis::Sessions)
redis_clear_raw_config!(Gitlab::Redis::SharedState)
end end
it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_sessions, :use_primary_store_as_default_for_sessions # 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
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 end
end end

View file

@ -32,4 +32,11 @@ module RedisHelpers
def redis_sessions_cleanup! def redis_sessions_cleanup!
Gitlab::Redis::Sessions.with(&:flushdb) Gitlab::Redis::Sessions.with(&:flushdb)
end 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 end

View file

@ -8,13 +8,13 @@ RSpec.shared_examples "redis_new_instance_shared_examples" do |name, fallback_cl
let(:fallback_config_file) { nil } let(:fallback_config_file) { nil }
before do 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) allow(fallback_class).to receive(:config_file_name).and_return(fallback_config_file)
end end
after do after do
fallback_class.remove_instance_variable(:@_raw_config) rescue nil redis_clear_raw_config!(fallback_class)
end end
it_behaves_like "redis_shared_examples" it_behaves_like "redis_shared_examples"

View file

@ -20,11 +20,11 @@ RSpec.shared_examples "redis_shared_examples" do
before do before do
allow(described_class).to receive(:config_file_name).and_return(Rails.root.join(config_file_name).to_s) 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 end
after do after do
clear_raw_config redis_clear_raw_config!(described_class)
end end
describe '.config_file_name' do describe '.config_file_name' do
@ -399,12 +399,6 @@ RSpec.shared_examples "redis_shared_examples" do
end end
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 def clear_pool
described_class.remove_instance_variable(:@pool) described_class.remove_instance_variable(:@pool)
rescue NameError rescue NameError