Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-03-06 00:12:29 +00:00
parent aa01c59edf
commit a2d5a577e3
65 changed files with 1819 additions and 1188 deletions

View File

@ -1 +1 @@
db58d685d85c5616bc90cdb806e9ee67cfc1c398
ed5bd1bce7be929eeccc3e737157b018d35ad5a7

View File

@ -59,7 +59,7 @@ export const initJobDetails = () => {
aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable),
duoFeaturesEnabled: parseBoolean(duoFeaturesEnabled),
pipelineTestReportUrl,
userRole: userRole?.toLowerCase(),
userRole,
},
render(h) {
return h(JobApp, {

View File

@ -4,9 +4,14 @@ import { formatTime } from '~/lib/utils/datetime_utility';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { sprintf, s__ } from '~/locale';
export default {
iconSize: 12,
i18n: {
statusDescription: (id) => sprintf(s__('Jobs|Status for job %{id}'), { id }),
},
components: {
CiIcon,
GlIcon,
@ -20,6 +25,13 @@ export default {
},
},
computed: {
jobId() {
const id = getIdFromGraphQLId(this.job.id);
return `#${id}`;
},
statusDescriptionId() {
return `ci-status-description-${this.jobId}`;
},
finishedTime() {
return this.job?.finishedAt;
},
@ -38,7 +50,12 @@ export default {
<template>
<div>
<ci-icon :status="job.detailedStatus" show-status-text />
<p :id="statusDescriptionId" class="gl-sr-only">{{ $options.i18n.statusDescription(jobId) }}</p>
<ci-icon
:status="job.detailedStatus"
show-status-text
:aria-describedby="statusDescriptionId"
/>
<div class="gl-ml-1 gl-mt-2 gl-text-sm gl-text-subtle">
<div v-if="duration" data-testid="job-duration">
<gl-icon

View File

@ -10,7 +10,11 @@ const ROLE_OWNER = 'owner';
export default {
USER_ROLES: Object.freeze([ROLE_DEVELOPER, ROLE_MAINTAINER, ROLE_OWNER]),
inject: ['projectPath', 'userRole'],
inject: {
projectPath: { default: '' },
fullPath: { default: '' },
userRole: { default: '' },
},
data() {
return {
@ -24,7 +28,7 @@ export default {
query: getPipelineVariablesMinimumOverrideRoleQuery,
variables() {
return {
fullPath: this.projectPath,
fullPath: this.projectPath || this.fullPath,
};
},
update({ project }) {
@ -53,7 +57,7 @@ export default {
return false;
}
const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole);
const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole?.toLowerCase());
const minRoleIndex = this.$options.USER_ROLES.indexOf(this.minimumRole);
return userRoleIndex >= minRoleIndex || false;

View File

@ -7,6 +7,7 @@ import { createAlert } from '~/alert';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SafeHtml from '~/vue_shared/directives/safe_html';
import PipelineInputsForm from '~/ci/common/pipeline_inputs/pipeline_inputs_form.vue';
import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
import { IDENTITY_VERIFICATION_REQUIRED_ERROR } from '../constants';
import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql';
import RefsDropdown from './refs_dropdown.vue';
@ -24,6 +25,7 @@ const i18n = {
export default {
name: 'PipelineNewForm',
i18n,
ROLE_MAINTAINER: 'maintainer',
components: {
GlAlert,
GlButton,
@ -37,7 +39,8 @@ export default {
import('ee_component/vue_shared/components/pipeline_account_verification_alert.vue'),
},
directives: { SafeHtml },
mixins: [glFeatureFlagsMixin()],
mixins: [glFeatureFlagsMixin(), PipelineVariablesPermissionsMixin],
inject: ['projectPath', 'userRole'],
props: {
pipelinesPath: {
type: String,
@ -68,10 +71,6 @@ export default {
required: false,
default: () => ({}),
},
projectPath: {
type: String,
required: true,
},
refParam: {
type: String,
required: false,
@ -86,10 +85,6 @@ export default {
type: Number,
required: true,
},
isMaintainer: {
type: Boolean,
required: true,
},
},
data() {
return {
@ -116,6 +111,9 @@ export default {
isPipelineInputsFeatureAvailable() {
return this.glFeatures.ciInputsForPipelines;
},
isMaintainer() {
return this.userRole?.toLowerCase() === this.$options.ROLE_MAINTAINER;
},
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
@ -246,7 +244,7 @@ export default {
</p>
</details>
</gl-alert>
<div class="gl-flex gl-flex-col gl-gap-7">
<div class="gl-flex gl-flex-col gl-gap-5">
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')" class="gl-mb-0">
<refs-dropdown
v-model="refValue"
@ -256,6 +254,7 @@ export default {
</gl-form-group>
<pipeline-inputs-form v-if="isPipelineInputsFeatureAvailable" />
<pipeline-variables-form
v-if="canViewPipelineVariables"
:file-params="fileParams"
:is-maintainer="isMaintainer"
:project-path="projectPath"

View File

@ -49,6 +49,7 @@ export default {
GlSprintf,
VariableValuesListbox,
},
inject: ['projectPath'],
props: {
fileParams: {
type: Object,
@ -59,10 +60,6 @@ export default {
type: Boolean,
required: true,
},
projectPath: {
type: String,
required: true,
},
refParam: {
type: String,
required: true,

View File

@ -8,6 +8,8 @@ const mountPipelineNewForm = (el) => {
// provide/inject
projectRefsEndpoint,
identityVerificationPath,
projectPath,
userRole,
// props
defaultBranch,
@ -17,11 +19,9 @@ const mountPipelineNewForm = (el) => {
pipelinesEditorPath,
canViewPipelineEditor,
projectId,
projectPath,
refParam,
settingsLink,
varParam,
isMaintainer,
} = el.dataset;
const variableParams = JSON.parse(varParam);
@ -44,6 +44,8 @@ const mountPipelineNewForm = (el) => {
// rendered if a specific error is returned from the backend after
// the create pipeline XHR request completes
identityVerificationRequired: true,
projectPath,
userRole,
},
render(createElement) {
return createElement(PipelineNewForm, {
@ -55,11 +57,9 @@ const mountPipelineNewForm = (el) => {
pipelinesEditorPath,
canViewPipelineEditor,
projectId,
projectPath,
refParam,
settingsLink,
variableParams,
isMaintainer,
},
});
},

View File

@ -14,6 +14,7 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import RefSelector from '~/ref/components/ref_selector.vue';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql';
import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql';
import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql';
@ -34,7 +35,16 @@ export default {
IntervalPatternInput,
PipelineVariablesFormGroup,
},
inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'],
mixins: [PipelineVariablesPermissionsMixin],
inject: [
'fullPath',
'projectId',
'defaultBranch',
'dailyLimit',
'settingsLink',
'schedulesPath',
'userRole',
],
props: {
timezoneData: {
type: Array,
@ -303,6 +313,7 @@ export default {
</gl-form-group>
<!--Variable List-->
<pipeline-variables-form-group
v-if="canViewPipelineVariables"
:initial-variables="variables"
:editing="editing"
@update-variables="updatedVariables = $event"

View File

@ -24,6 +24,7 @@ export default (selector, editing = false) => {
defaultBranch,
settingsLink,
schedulesPath,
userRole,
} = containerEl.dataset;
return new Vue({
@ -37,6 +38,7 @@ export default (selector, editing = false) => {
dailyLimit: dailyLimit ?? '',
settingsLink,
schedulesPath,
userRole,
},
render(createElement) {
return createElement(PipelineSchedulesForm, {

View File

@ -302,6 +302,7 @@
],
"WorkItemWidgetDefinition": [
"WorkItemWidgetDefinitionAssignees",
"WorkItemWidgetDefinitionCustomFields",
"WorkItemWidgetDefinitionCustomStatus",
"WorkItemWidgetDefinitionGeneric",
"WorkItemWidgetDefinitionHierarchy",

View File

@ -9,14 +9,15 @@ import { createAlert } from '~/alert';
import { TIMESTAMP_TYPES } from '~/vue_shared/components/resource_lists/constants';
import { FILTERED_SEARCH_TERM_KEY } from '~/projects/filtered_search_and_sort/constants';
import { ACCESS_LEVELS_INTEGER_TO_STRING } from '~/access_level/constants';
import { formatProjects } from '~/projects/your_work/utils';
import {
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '../constants';
import { formatProjects } from '../utils';
// Will be made more generic to work with groups and projects in future commits
export default {
name: 'YourWorkProjectsTabView',
name: 'TabView',
i18n: {
errorMessage: __(
'An error occurred loading the projects. Please refresh the page to try again.',

View File

@ -0,0 +1,321 @@
<script>
import { GlTabs, GlTab, GlBadge, GlFilteredSearchToken } from '@gitlab/ui';
import { isEqual, pick } from 'lodash';
import { __ } from '~/locale';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
} from '~/vue_shared/components/resource_lists/constants';
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { createAlert } from '~/alert';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
import {
SORT_OPTIONS,
SORT_DIRECTION_ASC,
SORT_DIRECTION_DESC,
SORT_OPTION_UPDATED,
SORT_OPTION_CREATED,
FILTERED_SEARCH_TERM_KEY,
FILTERED_SEARCH_NAMESPACE,
} from '~/projects/filtered_search_and_sort/constants';
import {
CONTRIBUTED_TAB,
CUSTOM_DASHBOARD_ROUTE_NAMES,
PROJECT_DASHBOARD_TABS,
} from '~/projects/your_work/constants';
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import {
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '../constants';
import userPreferencesUpdateMutation from '../graphql/mutations/user_preferences_update.mutation.graphql';
import TabView from './tab_view.vue';
// Will be made more generic to work with groups and projects in future commits
export default {
name: 'TabsWithList',
PROJECT_DASHBOARD_TABS,
i18n: {
projectCountError: __('An error occurred loading the project counts.'),
},
filteredSearchAndSort: {
sortOptions: SORT_OPTIONS,
namespace: FILTERED_SEARCH_NAMESPACE,
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
searchTermKey: FILTERED_SEARCH_TERM_KEY,
},
components: {
GlTabs,
GlTab,
GlBadge,
TabView,
FilteredSearchAndSort,
},
inject: ['initialSort', 'programmingLanguages'],
data() {
return {
activeTabIndex: this.initActiveTabIndex(),
counts: PROJECT_DASHBOARD_TABS.reduce((accumulator, tab) => {
return {
...accumulator,
[tab.value]: undefined,
};
}, {}),
};
},
apollo: {
counts() {
return {
query: projectCountsQuery,
update(response) {
const {
currentUser: { contributed, starred },
personal,
member,
inactive,
} = response;
return {
contributed: contributed.count,
starred: starred.count,
personal: personal.count,
member: member.count,
inactive: inactive.count,
};
},
error(error) {
createAlert({ message: this.$options.i18n.projectCountError, error, captureError: true });
},
};
},
},
computed: {
filteredSearchTokens() {
return [
{
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
icon: 'code',
title: __('Language'),
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: this.programmingLanguages.map(({ id, name }) => ({
// Cast to string so it matches value from query string
value: id.toString(),
title: name,
})),
},
{
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
icon: 'user',
title: __('Role'),
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: [
{
// Cast to string so it matches value from query string
value: ACCESS_LEVEL_OWNER_INTEGER.toString(),
title: __('Owner'),
},
],
},
];
},
sortQuery() {
return this.$route.query.sort;
},
sort() {
const sortOptionValues = SORT_OPTIONS.flatMap(({ value }) => [
`${value}_${SORT_DIRECTION_ASC}`,
`${value}_${SORT_DIRECTION_DESC}`,
]);
if (this.sortQuery && sortOptionValues.includes(this.sortQuery)) {
return this.sortQuery;
}
if (sortOptionValues.includes(this.initialSort)) {
return this.initialSort;
}
return `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`;
},
activeSortOption() {
return SORT_OPTIONS.find((sortItem) => this.sort.includes(sortItem.value));
},
isAscending() {
return this.sort.endsWith(SORT_DIRECTION_ASC);
},
startCursor() {
return this.$route.query[QUERY_PARAM_START_CURSOR];
},
endCursor() {
return this.$route.query[QUERY_PARAM_END_CURSOR];
},
routeQueryWithoutPagination() {
const {
[QUERY_PARAM_START_CURSOR]: startCursor,
[QUERY_PARAM_END_CURSOR]: endCursor,
...routeQuery
} = this.$route.query;
return routeQuery;
},
filters() {
const filters = pick(this.routeQueryWithoutPagination, [
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
FILTERED_SEARCH_TERM_KEY,
]);
// Normalize the property to Number since Vue Router 4 will
// return this and all other query variables as a string
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL] = Number(
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
);
return filters;
},
timestampType() {
const SORT_MAP = {
[SORT_OPTION_CREATED.value]: TIMESTAMP_TYPE_CREATED_AT,
[SORT_OPTION_UPDATED.value]: TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
};
return SORT_MAP[this.activeSortOption.value] || TIMESTAMP_TYPE_CREATED_AT;
},
},
methods: {
numberToMetricPrefix,
createSortQuery({ sortBy, isAscending }) {
return `${sortBy}_${isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC}`;
},
pushQuery(query) {
if (isEqual(this.$route.query, query)) {
return;
}
this.$router.push({ query });
},
initActiveTabIndex() {
return CUSTOM_DASHBOARD_ROUTE_NAMES.includes(this.$route.name)
? 0
: PROJECT_DASHBOARD_TABS.findIndex((tab) => tab.value === this.$route.name);
},
onTabUpdate(index) {
// This return will prevent us overwriting the root `/` and `/dashboard/projects` paths
// when we don't need to.
if (index === this.activeTabIndex) return;
this.activeTabIndex = index;
const tab = PROJECT_DASHBOARD_TABS[index] || CONTRIBUTED_TAB;
this.$router.push({ name: tab.value });
},
tabCount(tab) {
return this.counts[tab.value];
},
shouldShowCountBadge(tab) {
return this.tabCount(tab) !== undefined;
},
onSortDirectionChange(isAscending) {
const sort = this.createSortQuery({ sortBy: this.activeSortOption.value, isAscending });
this.updateSort(sort);
},
onSortByChange(sortBy) {
const sort = this.createSortQuery({ sortBy, isAscending: this.isAscending });
this.updateSort(sort);
},
updateSort(sort) {
this.pushQuery({ ...this.routeQueryWithoutPagination, sort });
this.userPreferencesUpdateMutate(sort);
},
onFilter(filters) {
const { sort } = this.$route.query;
this.pushQuery({ sort, ...filters });
},
onPageChange(pagination) {
this.pushQuery(
calculateGraphQLPaginationQueryParams({ ...pagination, routeQuery: this.$route.query }),
);
},
async userPreferencesUpdateMutate(sort) {
try {
await this.$apollo.mutate({
mutation: userPreferencesUpdateMutation,
variables: {
input: {
projectsSort: sort.toUpperCase(),
},
},
});
} catch (error) {
// Silently fail but capture exception in Sentry
Sentry.captureException(error);
}
},
},
};
</script>
<template>
<gl-tabs :value="activeTabIndex" @input="onTabUpdate">
<gl-tab v-for="tab in $options.PROJECT_DASHBOARD_TABS" :key="tab.text" lazy>
<template #title>
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
<span>{{ tab.text }}</span>
<gl-badge
v-if="shouldShowCountBadge(tab)"
size="sm"
class="gl-tab-counter-badge"
data-testid="tab-counter-badge"
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
>
</div>
</template>
<tab-view
v-if="tab.query"
:tab="tab"
:start-cursor="startCursor"
:end-cursor="endCursor"
:sort="sort"
:filters="filters"
:timestamp-type="timestampType"
@page-change="onPageChange"
/>
<template v-else>{{ tab.text }}</template>
</gl-tab>
<template #tabs-end>
<li class="gl-w-full">
<filtered-search-and-sort
class="gl-border-b-0"
:filtered-search-namespace="$options.filteredSearchAndSort.namespace"
:filtered-search-tokens="filteredSearchTokens"
:filtered-search-term-key="$options.filteredSearchAndSort.searchTermKey"
:filtered-search-recent-searches-storage-key="
$options.filteredSearchAndSort.recentSearchesStorageKey
"
:filtered-search-query="$route.query"
:is-ascending="isAscending"
:sort-options="$options.filteredSearchAndSort.sortOptions"
:active-sort-option="activeSortOption"
@filter="onFilter"
@sort-direction-change="onSortDirectionChange"
@sort-by-change="onSortByChange"
/>
</li>
</template>
</gl-tabs>
</template>

View File

@ -4,3 +4,6 @@ export const SORT_LABEL_NAME = __('Name');
export const SORT_LABEL_CREATED = __('Created date');
export const SORT_LABEL_UPDATED = __('Updated date');
export const SORT_LABEL_STARS = __('Stars');
export const FILTERED_SEARCH_TOKEN_LANGUAGE = 'language';
export const FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL = 'min_access_level';

View File

@ -1,318 +1,14 @@
<script>
import { GlTabs, GlTab, GlBadge, GlFilteredSearchToken } from '@gitlab/ui';
import { isEqual, pick } from 'lodash';
import { __ } from '~/locale';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
} from '~/vue_shared/components/resource_lists/constants';
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { createAlert } from '~/alert';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
import {
SORT_OPTIONS,
SORT_DIRECTION_ASC,
SORT_DIRECTION_DESC,
SORT_OPTION_UPDATED,
SORT_OPTION_CREATED,
FILTERED_SEARCH_TERM_KEY,
FILTERED_SEARCH_NAMESPACE,
} from '~/projects/filtered_search_and_sort/constants';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import {
CONTRIBUTED_TAB,
CUSTOM_DASHBOARD_ROUTE_NAMES,
PROJECT_DASHBOARD_TABS,
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '../constants';
import projectCountsQuery from '../graphql/queries/project_counts.query.graphql';
import userPreferencesUpdateMutation from '../graphql/mutations/user_preferences_update.mutation.graphql';
import TabView from './tab_view.vue';
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
export default {
name: 'YourWorkProjectsApp',
PROJECT_DASHBOARD_TABS,
i18n: {
projectCountError: __('An error occurred loading the project counts.'),
},
filteredSearchAndSort: {
sortOptions: SORT_OPTIONS,
namespace: FILTERED_SEARCH_NAMESPACE,
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
searchTermKey: FILTERED_SEARCH_TERM_KEY,
},
components: {
GlTabs,
GlTab,
GlBadge,
TabView,
FilteredSearchAndSort,
},
inject: ['initialSort', 'programmingLanguages'],
data() {
return {
activeTabIndex: this.initActiveTabIndex(),
counts: PROJECT_DASHBOARD_TABS.reduce((accumulator, tab) => {
return {
...accumulator,
[tab.value]: undefined,
};
}, {}),
};
},
apollo: {
counts() {
return {
query: projectCountsQuery,
update(response) {
const {
currentUser: { contributed, starred },
personal,
member,
inactive,
} = response;
return {
contributed: contributed.count,
starred: starred.count,
personal: personal.count,
member: member.count,
inactive: inactive.count,
};
},
error(error) {
createAlert({ message: this.$options.i18n.projectCountError, error, captureError: true });
},
};
},
},
computed: {
filteredSearchTokens() {
return [
{
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
icon: 'code',
title: __('Language'),
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: this.programmingLanguages.map(({ id, name }) => ({
// Cast to string so it matches value from query string
value: id.toString(),
title: name,
})),
},
{
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
icon: 'user',
title: __('Role'),
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: [
{
// Cast to string so it matches value from query string
value: ACCESS_LEVEL_OWNER_INTEGER.toString(),
title: __('Owner'),
},
],
},
];
},
sortQuery() {
return this.$route.query.sort;
},
sort() {
const sortOptionValues = SORT_OPTIONS.flatMap(({ value }) => [
`${value}_${SORT_DIRECTION_ASC}`,
`${value}_${SORT_DIRECTION_DESC}`,
]);
if (this.sortQuery && sortOptionValues.includes(this.sortQuery)) {
return this.sortQuery;
}
if (sortOptionValues.includes(this.initialSort)) {
return this.initialSort;
}
return `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`;
},
activeSortOption() {
return SORT_OPTIONS.find((sortItem) => this.sort.includes(sortItem.value));
},
isAscending() {
return this.sort.endsWith(SORT_DIRECTION_ASC);
},
startCursor() {
return this.$route.query[QUERY_PARAM_START_CURSOR];
},
endCursor() {
return this.$route.query[QUERY_PARAM_END_CURSOR];
},
routeQueryWithoutPagination() {
const {
[QUERY_PARAM_START_CURSOR]: startCursor,
[QUERY_PARAM_END_CURSOR]: endCursor,
...routeQuery
} = this.$route.query;
return routeQuery;
},
filters() {
const filters = pick(this.routeQueryWithoutPagination, [
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
FILTERED_SEARCH_TERM_KEY,
]);
// Normalize the property to Number since Vue Router 4 will
// return this and all other query variables as a string
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL] = Number(
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
);
return filters;
},
timestampType() {
const SORT_MAP = {
[SORT_OPTION_CREATED.value]: TIMESTAMP_TYPE_CREATED_AT,
[SORT_OPTION_UPDATED.value]: TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
};
return SORT_MAP[this.activeSortOption.value] || TIMESTAMP_TYPE_CREATED_AT;
},
},
methods: {
numberToMetricPrefix,
createSortQuery({ sortBy, isAscending }) {
return `${sortBy}_${isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC}`;
},
pushQuery(query) {
if (isEqual(this.$route.query, query)) {
return;
}
this.$router.push({ query });
},
initActiveTabIndex() {
return CUSTOM_DASHBOARD_ROUTE_NAMES.includes(this.$route.name)
? 0
: PROJECT_DASHBOARD_TABS.findIndex((tab) => tab.value === this.$route.name);
},
onTabUpdate(index) {
// This return will prevent us overwriting the root `/` and `/dashboard/projects` paths
// when we don't need to.
if (index === this.activeTabIndex) return;
this.activeTabIndex = index;
const tab = PROJECT_DASHBOARD_TABS[index] || CONTRIBUTED_TAB;
this.$router.push({ name: tab.value });
},
tabCount(tab) {
return this.counts[tab.value];
},
shouldShowCountBadge(tab) {
return this.tabCount(tab) !== undefined;
},
onSortDirectionChange(isAscending) {
const sort = this.createSortQuery({ sortBy: this.activeSortOption.value, isAscending });
this.updateSort(sort);
},
onSortByChange(sortBy) {
const sort = this.createSortQuery({ sortBy, isAscending: this.isAscending });
this.updateSort(sort);
},
updateSort(sort) {
this.pushQuery({ ...this.routeQueryWithoutPagination, sort });
this.userPreferencesUpdateMutate(sort);
},
onFilter(filters) {
const { sort } = this.$route.query;
this.pushQuery({ sort, ...filters });
},
onPageChange(pagination) {
this.pushQuery(
calculateGraphQLPaginationQueryParams({ ...pagination, routeQuery: this.$route.query }),
);
},
async userPreferencesUpdateMutate(sort) {
try {
await this.$apollo.mutate({
mutation: userPreferencesUpdateMutation,
variables: {
input: {
projectsSort: sort.toUpperCase(),
},
},
});
} catch (error) {
// Silently fail but capture exception in Sentry
Sentry.captureException(error);
}
},
TabsWithList,
},
};
</script>
<template>
<gl-tabs :value="activeTabIndex" @input="onTabUpdate">
<gl-tab v-for="tab in $options.PROJECT_DASHBOARD_TABS" :key="tab.text" lazy>
<template #title>
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
<span>{{ tab.text }}</span>
<gl-badge
v-if="shouldShowCountBadge(tab)"
size="sm"
class="gl-tab-counter-badge"
data-testid="tab-counter-badge"
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
>
</div>
</template>
<tab-view
v-if="tab.query"
:tab="tab"
:start-cursor="startCursor"
:end-cursor="endCursor"
:sort="sort"
:filters="filters"
:timestamp-type="timestampType"
@page-change="onPageChange"
/>
<template v-else>{{ tab.text }}</template>
</gl-tab>
<template #tabs-end>
<li class="gl-w-full">
<filtered-search-and-sort
class="gl-border-b-0"
:filtered-search-namespace="$options.filteredSearchAndSort.namespace"
:filtered-search-tokens="filteredSearchTokens"
:filtered-search-term-key="$options.filteredSearchAndSort.searchTermKey"
:filtered-search-recent-searches-storage-key="
$options.filteredSearchAndSort.recentSearchesStorageKey
"
:filtered-search-query="$route.query"
:is-ascending="isAscending"
:sort-options="$options.filteredSearchAndSort.sortOptions"
:active-sort-option="activeSortOption"
@filter="onFilter"
@sort-direction-change="onSortDirectionChange"
@sort-by-change="onSortByChange"
/>
</li>
</template>
</gl-tabs>
<tabs-with-list />
</template>

View File

@ -95,5 +95,3 @@ export const CUSTOM_DASHBOARD_ROUTE_NAMES = [
export const FILTERED_SEARCH_NAMESPACE = 'dashboard';
export const FILTERED_SEARCH_TERM_KEY = 'name';
export const FILTERED_SEARCH_TOKEN_LANGUAGE = 'language';
export const FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL = 'min_access_level';

View File

@ -120,6 +120,7 @@ export default {
data() {
return {
isEditing: false,
isUpdating: false,
workItem: {},
};
},
@ -241,9 +242,11 @@ export default {
this.isEditing = true;
updateDraft(this.autosaveKey, this.note.body);
},
async updateNote({ commentText }) {
async updateNote({ commentText, executeOptimisticResponse = true }) {
try {
this.isEditing = false;
this.isUpdating = true;
await this.$apollo.mutate({
mutation: updateWorkItemNoteMutation,
variables: {
@ -252,15 +255,18 @@ export default {
body: commentText,
},
},
optimisticResponse: {
updateNote: {
errors: [],
note: {
...this.note,
bodyHtml: renderMarkdown(commentText),
},
},
},
// Ignore this when toggling checkbox https://gitlab.com/gitlab-org/gitlab/-/issues/521723
optimisticResponse: executeOptimisticResponse
? {
updateNote: {
errors: [],
note: {
...this.note,
bodyHtml: renderMarkdown(commentText),
},
},
}
: undefined,
});
clearDraft(this.autosaveKey);
} catch (error) {
@ -268,6 +274,8 @@ export default {
this.isEditing = true;
this.$emit('error', __('Something went wrong when updating a comment. Please try again'));
Sentry.captureException(error);
} finally {
this.isUpdating = false;
}
},
getNewAssigneesAndWidget() {
@ -428,7 +436,13 @@ export default {
@submitForm="updateNote"
/>
<div v-else class="timeline-discussion-body">
<note-body ref="noteBody" :note="note" :has-replies="hasReplies" />
<note-body
ref="noteBody"
:note="note"
:has-replies="hasReplies"
:is-updating="isUpdating"
@updateNote="updateNote"
/>
</div>
<edited-at
v-if="note.lastEditedBy && !isEditing"

View File

@ -1,6 +1,9 @@
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils';
const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
export default {
name: 'WorkItemNoteBody',
@ -17,6 +20,11 @@ export default {
required: false,
default: false,
},
isUpdating: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
'note.bodyHtml': {
@ -27,6 +35,12 @@ export default {
}
await this.$nextTick();
this.renderGFM();
this.disableCheckboxes(false);
},
},
isUpdating: {
handler(isUpdating) {
this.disableCheckboxes(isUpdating);
},
},
},
@ -35,6 +49,32 @@ export default {
renderGFM(this.$refs['note-body']);
gl?.lazyLoader?.searchLazyImages();
},
disableCheckboxes(disabled) {
this.$el.querySelectorAll('.task-list-item-checkbox').forEach((checkbox) => {
checkbox.disabled = disabled; // eslint-disable-line no-param-reassign
});
},
toggleCheckboxes(event) {
const { target } = event;
if (!isCheckbox(target)) {
return;
}
const { sourcepos } = target.parentElement.dataset;
if (!sourcepos) {
return;
}
const commentText = toggleMarkCheckboxes({
rawMarkdown: this.note.body,
checkboxChecked: target.checked,
sourcepos,
});
this.$emit('updateNote', { commentText, executeOptimisticResponse: false });
},
},
safeHtmlConfig: {
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
@ -48,6 +88,7 @@ export default {
v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml"
class="note-text md"
data-testid="work-item-note-body"
@change="toggleCheckboxes"
></div>
</div>
</template>

View File

@ -67,6 +67,7 @@ module LooksAhead
def node_selection(selection = lookahead)
return selection unless selection&.selected?
return selection if selection.field.type.list?
return selection.selection(:edges).selection(:node) if selection.selects?(:edges)
# Will return a NullSelection object if :nodes is not a selection. This

View File

@ -10,7 +10,8 @@ module Ci
project_id: project.id,
default_branch: project.default_branch,
settings_link: project_settings_ci_cd_path(project),
schedules_path: pipeline_schedules_path(project)
schedules_path: pipeline_schedules_path(project),
user_role: current_user ? project.team.human_max_access(current_user.id) : nil
}
end
end

View File

@ -90,7 +90,7 @@ module Ci
project_refs_endpoint: refs_project_path(project, sort: 'updated_desc'),
settings_link: project_settings_ci_cd_path(project),
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT,
is_maintainer: can?(current_user, :maintainer_access, project)
user_role: project.team.human_max_access(current_user&.id)
}
end

View File

@ -551,7 +551,7 @@ module Ci
def registration_available?
authenticated_user_registration_type? &&
created_at > REGISTRATION_AVAILABILITY_TIME.ago &&
!runner_managers.any?
started_creation_state?
end
# CI_JOB_JWT_V2 that uses this method is deprecated

View File

@ -0,0 +1,14 @@
---
# Warning: gitlab_base.SelfReferential
# Checks for wordy, self-referential phrases.
#
# For a list of all options, see https://vale.sh/docs/topics/styles/
extends: existence
message: "Rewrite '%s'. Talk directly about the feature or purpose instead."
ignorecase: true
nonword: true
vocab: false
level: warning
link: https://docs.gitlab.com/development/documentation/styleguide/#self-referential-writing
tokens:
- This (page|guide) (builds|contains|covers|describes|documents|explains|guides|lists|offers|provides|shows)

View File

@ -20,6 +20,7 @@ To choose the packages you want to synchronize with the GitLab Package Metadata
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Security and compliance**.
1. Expand **License Compliance**.
1. In **Package registry metadata to sync**, select or clear checkboxes for the
package registries that you want to sync.
1. Select **Save changes**.

View File

@ -26884,7 +26884,7 @@ four standard [pagination arguments](#pagination-arguments):
| <a id="groupcustomfieldsactive"></a>`active` | [`Boolean`](#boolean) | Filter for active fields. If `false`, excludes active fields. If `true`, returns only active fields. |
| <a id="groupcustomfieldsfieldtype"></a>`fieldType` | [`CustomFieldType`](#customfieldtype) | Filter for selected field type. |
| <a id="groupcustomfieldssearch"></a>`search` | [`String`](#string) | Search query for custom field name. |
| <a id="groupcustomfieldsworkitemtypeids"></a>`workItemTypeIds` | [`[WorkItemsTypeID!]`](#workitemstypeid) | Filter custom fields associated to the given work item types. If empty, returns custom fields not associated to any work item type. |
| <a id="groupcustomfieldsworkitemtypeids"></a>`workItemTypeIds` | [`[WorkItemsTypeID!]`](#workitemstypeid) | Filter custom fields associated to any of the given work item types. If empty, returns custom fields not associated to any work item type. |
##### `Group.customizableDashboardVisualizations`
@ -39918,6 +39918,17 @@ Represents an assignees widget definition.
| <a id="workitemwidgetdefinitionassigneescaninvitemembers"></a>`canInviteMembers` | [`Boolean!`](#boolean) | Indicates whether the current user can invite members to the work item's parent. |
| <a id="workitemwidgetdefinitionassigneestype"></a>`type` | [`WorkItemWidgetType!`](#workitemwidgettype) | Widget type. |
### `WorkItemWidgetDefinitionCustomFields`
Represents a custom fields widget definition.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemwidgetdefinitioncustomfieldscustomfields"></a>`customFields` {{< icon name="warning-solid" >}} | [`[CustomField!]`](#customfield) | **Introduced** in GitLab 17.10. **Status**: Experiment. Custom fields available for the work item type. Available only when feature flag `custom_fields_feature` is enabled. |
| <a id="workitemwidgetdefinitioncustomfieldstype"></a>`type` | [`WorkItemWidgetType!`](#workitemwidgettype) | Widget type. |
### `WorkItemWidgetDefinitionCustomStatus`
Represents an Custom Status widget definition.
@ -46631,6 +46642,7 @@ Implementations:
Implementations:
- [`WorkItemWidgetDefinitionAssignees`](#workitemwidgetdefinitionassignees)
- [`WorkItemWidgetDefinitionCustomFields`](#workitemwidgetdefinitioncustomfields)
- [`WorkItemWidgetDefinitionCustomStatus`](#workitemwidgetdefinitioncustomstatus)
- [`WorkItemWidgetDefinitionGeneric`](#workitemwidgetdefinitiongeneric)
- [`WorkItemWidgetDefinitionHierarchy`](#workitemwidgetdefinitionhierarchy)

View File

@ -12,9 +12,9 @@ title: Groups API
{{< /details >}}
Interact with [groups](../user/group/_index.md) by using the REST API.
Use the Groups API to list and manage GitLab groups through REST API calls. For more information, see [groups](../user/group/_index.md).
The fields returned in responses vary based on the [permissions](../user/permissions.md) of the authenticated user.
Endpoint responses might vary based on the [permissions](../user/permissions.md) of the authenticated user in the group.
## Get a single group

View File

@ -229,6 +229,8 @@ Use:
- GitLab has different types of pipelines to help address your development needs.
Tested in [`SelfReferential.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab_base/SelfReferential.yml).
### Capitalization
As a company, we tend toward lowercase.

View File

@ -210,7 +210,8 @@ Use **offline environment** to describe installations that have physical barrier
## allow, enable
Try to avoid **allow** and **enable**, unless you are talking about security-related features.
Try to avoid **allow** and **enable**, unless you are talking about security-related features or the
state of a feature flag.
Use:

View File

@ -276,6 +276,7 @@ The table below lists current exit codes and their meanings:
|167 | `gitlab.com` overloaded |
|168 | gRPC resource exhausted |
|169 | SQL query limit exceeded |
|170 | SQL table is write protected |
This list can be expanded as new failure patterns emerge. To avoid conflicts with standard Bash exit codes, new custom codes must be 160 or higher.

View File

@ -0,0 +1,114 @@
---
stage: Application Security Testing
group: Static Analysis
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Analyze
---
Analysis is the third phase of the vulnerability management lifecycle: detect, triage, analyze,
remediate.
Analysis is the process of evaluating the details of a vulnerability to determine if it can and
should be remediated. Vulnerabilities can be triaged in bulk but analysis must be done individually.
Prioritize analysis of each vulnerability according to its severity and associated risk. As part of
a risk management framework, analysis helps ensure resources are applied where they're most
effective.
Use the data contained in the [security dashboard](../security_dashboard/_index.md) and the
[vulnerability report](../vulnerability_report/_index.md) to help narrow your focus. According the
vulnerability management lifecycle, only confirmed vulnerabilities need to be analyzed. To focus on
only these, use the following filter criteria in the vulnerability report:
- **Status:** Confirmed
Use additional vulnerability report filters to narrow your focus further. For more details, see
[Analysis strategies](#analysis-strategies).
## Risk analysis
You should conduct vulnerability analysis according to a risk assessment framework. If you're not
already using a risk assessment framework, consider the following:
- SANS Institute [Vulnerability Management Framework](https://www.sans.org/blog/the-vulnerability-assessment-framework/)
- OWASP [Threat and Safeguard Matrix (TaSM)](https://owasp.org/www-project-threat-and-safeguard-matrix/)
Calculating the risk score of a vulnerability depends on criteria that are specific to your
organization. A basic risk score formula is:
Risk = Likelihood x Impact
Both the likelihood and impact numbers vary according to the vulnerability and your environment.
Determining these numbers and calculating a risk score may require some information not available in
GitLab. Instead, you must calculate these according to your risk management framework. After
calculating these, record them in the issue you raised for the vulnerability.
Generally, the amount of time and effort spent on a vulnerability should be proportional to its
risk. For example, you might choose to analyze only vulnerabilities of critical and high risk and
dismiss the rest. You should make this decision according to your risk threshold for
vulnerabilities.
## Analysis strategies
Use a risk assessment framework to help guide your vulnerability analysis process. The following
strategies may also help.
### Prioritize vulnerabilities of highest severity
To help identify vulnerabilities of highest severity:
- If you've not already done this in the triage phase, use the
[Vulnerability Prioritizer CI/CD component](../vulnerabilities/risk_assessment_data.md#vulnerability-prioritizer)
to help prioritize vulnerabilities for analysis.
- For each group, use the following filter criteria in the vulnerability report to prioritize
analysis of vulnerabilities by severity:
- **Status:** Confirmed
- **Activity:** Still detected
- **Group by:** Severity
- Prioritize vulnerability triage on your highest-priority projects - for example, applications
deployed to customers.
### Prioritize vulnerabilities that have a solution available
Some vulnerabilities have a solution available, for example "Upgrade from version 13.2 to 13.8".
This reduces the time taken to analyze and remediate these vulnerabilities. Some solutions are
available only if GitLab Duo is enabled.
Use the following filter criteria in the vulnerability report to identify vulnerabilities that have
a solution available.
- For vulnerabilities detected by SBOM scanning, use the criteria:
- **Status:** Confirmed
- **Activity:** Has a solution
- For vulnerabilities detected by SAST, use the criteria:
- **Status:** Confirmed
- **Activity:** Vulnerability Resolution available
## Vulnerability details and action
Every vulnerability has a [vulnerability page](../vulnerabilities/_index.md) which contains details
including when it was detected, how it was detected, its severity rating, and a complete log. Use
this information to help analyze a vulnerability.
The following tips may also help you analyze a vulnerability:
- Use [GitLab Duo Vulnerability Explanation](../vulnerabilities/_index.md#explaining-a-vulnerability)
to help explain the vulnerability and suggest a remediation. Available only for vulnerabilities
detected by SAST.
- Use [security training](../vulnerabilities/_index.md#view-security-training-for-a-vulnerability)
provided by third-party training vendors to help understand the nature of a specific
vulnerability.
After analyzing each confirmed vulnerability you should either:
- Leave its status as **Confirmed** if you decide it should be remediated.
- Change its status to **Dismissed** if you decide it should not be remediated.
If you confirm a vulnerability:
1. [Create an issue](../vulnerabilities/_index.md#create-a-gitlab-issue-for-a-vulnerability) to
track, document, and manage the remediation work.
1. Continue to the remediation phase of the vulnerability management lifecycle.
If you dismiss a vulnerability you must provide a brief comment that states why you've dismissed
it. Dismissed vulnerabilities are ignored if detected in subsequent scans. Vulnerability records
are permanent but you can change a vulnerability's status at any time.

View File

@ -49,11 +49,12 @@ Workflow:
## Prerequisites
To use Workflow:
Before you can use Duo Workflow, you must:
- You must have [completed setup](set_up.md).
- You must have an account on GitLab.com.
- You must have a project that meets the following requirements:
- [Install Visual Studio Code](https://code.visualstudio.com/download) (VS Code).
- [Set up the GitLab Workflow extension for VS Code](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow#setup). Minimum version 5.16.0.
- Have an account on GitLab.com.
- Have a project that meets the following requirements:
- The project is on GitLab.com.
- You have at least the Developer role.
- The project belongs to a [group namespace](../namespace/_index.md) with an Ultimate subscription.
@ -62,6 +63,8 @@ To use Workflow:
- The repository you want to work with should be small or medium-sized.
Workflow can be slow or fail for large repositories.
To isolate GitLab Duo Workflow in a Docker container, you must complete the [Docker setup](docker_set_up.md). This is not the preferred method to run Duo Workflow.
## Use Workflow in VS Code
To use Workflow in VS Code:

View File

@ -0,0 +1,84 @@
---
stage: AI-powered
group: Duo Workflow
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Set up Docker for GitLab Duo Workflow
---
{{< details >}}
- Tier: Ultimate
- Offering: GitLab.com
- Status: Experiment
{{< /details >}}
{{< alert type="warning" >}}
This feature is considered [experimental](../../policy/development_stages_support.md) and is not intended for customer usage outside of initial design partners. We expect major changes to this feature.
{{< /alert >}}
Use the following guide to set up GitLab Duo Workflow with Docker.
This is not the preferred method to run Workflow.
## Install Docker and set the socket file path
Workflow needs an execution platform like Docker where it can execute arbitrary code,
read and write files, and make API calls to GitLab.
If you are on macOS or Linux, you can either:
- Use the [automated setup script](docker_set_up.md#automated-setup). Recommended.
- Follow the [manual setup](docker_set_up.md#manual-setup).
If you are not on macOS or Linux, follow the [manual setup](docker_set_up.md#manual-setup).
### Automated setup
The automated setup script:
- Installs [Docker](https://formulae.brew.sh/formula/docker) and [Colima](https://github.com/abiosoft/colima).
- Sets Docker socket path in VS Code settings.
You can run the script with the `--dry-run` flag to check the dependencies
that get installed with the script.
1. Download the [setup script](https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/blob/main/scripts/install-runtime).
```shell
wget https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/raw/main/scripts/install-runtime
```
1. Run the script.
```shell
chmod +x install-runtime
./install-runtime
```
### Manual setup
1. Install a Docker container engine, such as [Rancher Desktop](https://docs.rancherdesktop.io/getting-started/installation/).
1. Set the Docker socket path and Docker settings in VS Code:
1. Open VS Code, then open its settings:
- On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
- On Windows and Linux: <kbd>Ctrl</kbd> + <kbd>,</kbd>
1. In the upper-right corner, select the **Open Settings (JSON)** icon.
1. Add the Docker socket path setting `gitlab.duoWorkflow.dockerSocket`, according to your container manager, and save your settings file.
Some examples for common container managers on macOS, where you would replace `<your_user>` with your user's home folder:
- Rancher Desktop:
```json
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.rd/docker.sock",
"gitlab.duoWorkflow.useDocker": true,
```
- Colima:
```json
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.colima/default/docker.sock",
"gitlab.duoWorkflow.useDocker": true,
```

View File

@ -1,90 +1,13 @@
---
stage: AI-powered
group: Duo Workflow
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Set up GitLab Duo Workflow
redirect_to: 'docker_set_up.md'
remove_date: '2025-06-05'
---
{{< details >}}
<!-- markdownlint-disable -->
- Tier: Ultimate
- Offering: GitLab.com
- Status: Experiment
This document was moved to [another location](docker_set_up.md).
{{< /details >}}
{{< alert type="warning" >}}
This feature is considered [experimental](../../policy/development_stages_support.md) and is not intended for customer usage outside of initial design partners. We expect major changes to this feature.
{{< /alert >}}
Use the following guide to set up GitLab Duo Workflow.
## Prerequisites
Before you can set up Workflow:
- [Install Visual Studio Code](https://code.visualstudio.com/download) (VS Code).
- [Install and set up](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow#setup)
the GitLab Workflow extension for VS Code. Minimum version 5.16.0.
Then, complete the following steps.
## Install Docker and set the socket file path
Workflow needs an execution platform like Docker where it can execute arbitrary code,
read and write files, and make API calls to GitLab.
If you are on macOS or Linux, you can either:
- Use the [automated setup script](set_up.md#automated-setup). Recommended.
- Follow the [manual setup](set_up.md#manual-setup).
If you are not on macOS or Linux, follow the [manual setup](set_up.md#manual-setup).
### Automated setup
The automated setup script:
- Installs [Docker](https://formulae.brew.sh/formula/docker) and [Colima](https://github.com/abiosoft/colima).
- Sets Docker socket path in VS Code settings.
You can run the script with the `--dry-run` flag to check the dependencies
that get installed with the script.
1. Download the [setup script](https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/blob/main/scripts/install-runtime).
```shell
wget https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/raw/main/scripts/install-runtime
```
1. Run the script.
```shell
chmod +x install-runtime
./install-runtime
```
### Manual setup
1. Install a Docker container engine, such as [Rancher Desktop](https://docs.rancherdesktop.io/getting-started/installation/).
1. Set the Docker socket path in VS Code:
1. Open VS Code, then open its settings:
- On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
- On Windows and Linux: <kbd>Ctrl</kbd> + <kbd>,</kbd>
1. In the upper-right corner, select the **Open Settings (JSON)** icon.
1. Add the Docker socket path setting `gitlab.duoWorkflow.dockerSocket`, according to your container manager, and save your settings file.
Some examples for common container managers on macOS, where you would replace `<your_user>` with your user's home folder:
- Rancher Desktop:
```json
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.rd/docker.sock",
```
- Colima:
```json
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.colima/default/docker.sock",
```
<!-- This redirect file can be deleted after <2025-06-05>. -->
<!-- Redirects that point to other docs in the same project expire in three months. -->
<!-- Redirects that point to docs in a different project or site (link is not relative and starts with `https:`) expire in one year. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->

View File

@ -19,25 +19,14 @@ This feature is considered [experimental](../../policy/development_stages_suppor
{{< /alert >}}
## General guidance
If you encounter issues:
1. Ensure that you have the latest version of the GitLab Workflow extension.
1. Ensure that the project you want to use it with meets the [prerequisites](set_up.md#prerequisites).
1. Ensure that the project you want to use it with meets the [prerequisites](_index.md#prerequisites).
1. Ensure that the folder you opened in VS Code has a Git repository for your GitLab project.
1. Ensure that you've checked out the branch for the code you'd like to change.
1. Check your Docker configuration:
1. [Install Docker and set the socket file path](set_up.md#install-docker-and-set-the-socket-file-path).
1. Restart your container manager. For example, if you use Colima, `colima restart`.
1. Pull the base Docker image:
```shell
docker pull registry.gitlab.com/gitlab-org/duo-workflow/default-docker-image/workflow-generic-image:v0.0.4
```
1. For permission issues, ensure your operating system user has the necessary Docker permissions.
1. Verify Docker's internet connectivity by executing the command `docker image pull redhat/ubi8`.
If this does not work, the DNS configuration of Colima might be at fault.
Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
1. Check local debugging logs:
1. For more output in the logs, open the settings:
1. On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
@ -46,7 +35,23 @@ If you encounter issues:
1. Check the language server logs:
1. To open the logs in VS Code, select **View** > **Output**. In the output panel at the bottom, in the top-right corner, select **GitLab Workflow** or **GitLab Language Server** from the list.
1. Review for errors, warnings, connection issues, or authentication problems.
1. Check the executor logs:
1. Use `docker ps -a | grep duo-workflow` to get the list of Workflow containers and their ids.
1. Use `docker logs <container_id>` to view the logs for the specific container.
1. Examine the [Workflow Service production LangSmith trace](https://smith.langchain.com/o/477de7ad-583e-47b6-a1c4-c4a0300e7aca/projects/p/5409132b-2cf3-4df8-9f14-70204f90ed9b?timeModel=%7B%22duration%22%3A%227d%22%7D&tab=0).
## Docker guidance
If you encounter issues with your Docker setup for Duo Workflow, try the following steps.
1. [Install Docker and set the socket file path](docker_set_up.md#install-docker-and-set-the-socket-file-path).
1. Restart your container manager. For example, if you use Colima, `colima restart`.
1. Pull the base Docker image:
```shell
docker pull registry.gitlab.com/gitlab-org/duo-workflow/default-docker-image/workflow-generic-image:v0.0.4
```
1. For permission issues, ensure your operating system user has the necessary Docker permissions.
1. Verify Docker's internet connectivity by executing the command `docker image pull redhat/ubi8`.
If this does not work, the DNS configuration of Colima might be at fault.
Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
1. Check the executor logs:
1. Use `docker ps -a | grep duo-workflow` to get the list of Workflow containers and their ids.
1. Use `docker logs <container_id>` to view the logs for the specific container.

View File

@ -101,6 +101,16 @@ You can mention `@GitLabDuo` in comments to interact with GitLab Duo on your mer
Interactions with GitLab Duo can help to improve the suggestions and feedback as you work to improve your merge request.
### Automatic reviews from GitLab Duo
To enable `@GitLabDuo` to automatically review merge requests, edit your
[merge request template](../../../user/project/description_templates.md#create-a-merge-request-template)
and add the line `/assign_reviewer @GitLabDuo`. Add this line to your default template,
and any other templates in your project where you want `@GitLabDuo` to perform a review.
Additional settings and configuration are planned. To that work, see
[issue 506537](https://gitlab.com/gitlab-org/gitlab/-/issues/506537).
## Summarize a code review
{{< details >}}

View File

@ -2,12 +2,17 @@
module Gitlab
class Blame
include Gitlab::Utils::StrongMemoize
IGNORE_REVS_FILE_NAME = '.git-blame-ignore-revs'
attr_accessor :blob, :commit, :range
def initialize(blob, commit, range: nil)
def initialize(blob, commit, range: nil, ignore_revs: nil)
@blob = blob
@commit = commit
@range = range
@ignore_revs = ignore_revs
end
def first_line
@ -41,9 +46,23 @@ module Gitlab
private
attr_reader :ignore_revs
def blame
@blame ||= Gitlab::Git::Blame.new(repository, @commit.id, @blob.path, range: range)
Gitlab::Git::Blame.new(
repository,
@commit.id,
@blob.path,
range: range,
ignore_revisions_blob: ignore_revs ? default_ignore_revisions_ref : nil
)
end
strong_memoize_attr :blame
def default_ignore_revisions_ref
"refs/heads/#{project.default_branch}:#{IGNORE_REVS_FILE_NAME}"
end
strong_memoize_attr :default_ignore_revisions_ref
def highlighted_lines
@blob.load_all_data!

View File

@ -10,12 +10,13 @@ module Gitlab
attr_reader :lines, :blames, :range
def initialize(repository, sha, path, range: nil)
def initialize(repository, sha, path, range: nil, ignore_revisions_blob: nil)
@repo = repository
@sha = sha
@path = path
@range = range
@lines = []
@ignore_revisions_blob = ignore_revisions_blob
@blames = load_blame
end
@ -27,6 +28,8 @@ module Gitlab
private
attr_reader :ignore_revisions_blob
def range_spec
"#{range.first},#{range.last}" if range
end
@ -37,7 +40,8 @@ module Gitlab
end
def fetch_raw_blame
@repo.gitaly_commit_client.raw_blame(@sha, @path, range: range_spec)
@repo.gitaly_commit_client.raw_blame(@sha, @path, range: range_spec,
ignore_revisions_blob: ignore_revisions_blob)
rescue ArgumentError
# Return an empty result when the blame range is out-of-range or path is not found
""

View File

@ -3767,6 +3767,15 @@ msgstr ""
msgid "Adds this %{issuable_type} as related to the %{issuable_type} it was created from"
msgstr ""
msgid "AdherenceReport|Have questions or thoughts on the new improvements we made? %{linkStart}Please provide feedback on your experience%{linkEnd}."
msgstr ""
msgid "AdherenceReport|Learn more about the changes in our %{linkStart}documentation%{linkEnd}."
msgstr ""
msgid "AdherenceReport|We've updated the Adherence Report with new features to enhance your compliance workflow."
msgstr ""
msgid "Adjust how frequently the GitLab UI polls for updates."
msgstr ""
@ -33126,6 +33135,9 @@ msgstr ""
msgid "Jobs|Stage"
msgstr ""
msgid "Jobs|Status for job %{id}"
msgstr ""
msgid "Jobs|There was a problem fetching the failed jobs."
msgstr ""

View File

@ -63,7 +63,7 @@
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language-rust": "0.4.0",
"@gitlab/svgs": "3.123.0",
"@gitlab/ui": "108.10.0",
"@gitlab/ui": "109.0.0",
"@gitlab/vue-router-vue3": "npm:vue-router@4.5.0",
"@gitlab/vuex-vue3": "npm:vuex@4.1.0",
"@gitlab/web-ide": "^0.0.1-dev-20250211142744",

View File

@ -543,7 +543,7 @@ function log_disk_usage() {
# all functions below are for customizing CI job exit code
function run_with_custom_exit_code() {
set +e # temprorarily disable exit on error to prevent premature exit
set +e # temporarily disable exit on error to prevent premature exit
# runs command passed in as argument, save standard error and standard output
output=$("$@" 2>&1)
@ -600,6 +600,9 @@ function find_custom_exit_code() {
-e "500 Internal Server Error" \
-e "Internal Server Error 500" \
-e "502 Bad Gateway" \
-e "502 Server Error" \
-e "502 \"Bad Gateway\"" \
-e "status code: 502" \
-e "503 Service Unavailable" "$trace_file"; then
echoerr "Detected 5XX error. Changing exit code to 161."
exit_code=161
@ -648,6 +651,10 @@ function find_custom_exit_code() {
echoerr "Detected Gitlab::QueryLimiting::Transaction::ThresholdExceededError. Changing exit code to 169."
exit_code=169
elif grep -i -q -e \
"is write protected within this Gitlab database" "$trace_file"; then
echoerr "Detected SQL table is write-protected error in job trace. Changing exit code to 170."
exit_code=170
else
echoinfo "not changing exit code"
fi

View File

@ -43,11 +43,15 @@ RSpec.describe Admin::RunnersController, feature_category: :fleet_visibility do
end
end
describe '#register' do
describe '#register', :freeze_time do
subject(:register) { get :register, params: { id: new_runner.id } }
let(:new_runner) do
create(:ci_runner, :unregistered, *runner_traits, registration_type: :authenticated_user)
end
context 'when runner can be registered after creation' do
let_it_be(:new_runner) { create(:ci_runner, registration_type: :authenticated_user) }
let(:runner_traits) { [:created_before_registration_deadline] }
it 'renders a :register template' do
register
@ -58,7 +62,7 @@ RSpec.describe Admin::RunnersController, feature_category: :fleet_visibility do
end
context 'when runner cannot be registered after creation' do
let_it_be(:new_runner) { runner }
let(:runner_traits) { [:created_after_registration_deadline] }
it 'returns :not_found' do
register

View File

@ -151,16 +151,20 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
end
end
describe '#register' do
describe '#register', :freeze_time do
subject(:register) { get :register, params: { group_id: group, id: new_runner } }
let(:new_runner) do
create(:ci_runner, :unregistered, *runner_traits, :group, groups: [group], registration_type: :authenticated_user)
end
context 'when user is owner' do
before_all do
group.add_owner(user)
end
context 'when runner can be registered after creation' do
let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
let(:runner_traits) { [:created_before_registration_deadline] }
it 'renders a :register template' do
register
@ -171,7 +175,7 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
end
context 'when runner cannot be registered after creation' do
let_it_be(:new_runner) { runner }
let(:runner_traits) { [:created_after_registration_deadline] }
it 'returns :not_found' do
register
@ -187,7 +191,7 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
end
context 'when runner can be registered after creation' do
let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
let(:runner_traits) { [:created_before_registration_deadline] }
it 'returns :not_found' do
register

View File

@ -53,20 +53,24 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
end
end
describe '#register' do
describe '#register', :freeze_time do
subject(:register) do
get :register, params: { namespace_id: project.namespace, project_id: project, id: new_runner }
end
let(:new_runner) do
create(
:ci_runner, :unregistered, *runner_traits, :project, projects: [project], registration_type: :authenticated_user
)
end
context 'when user is maintainer' do
before_all do
project.add_maintainer(user)
end
context 'when runner can be registered after creation' do
let_it_be(:new_runner) do
create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user)
end
let(:runner_traits) { [:created_before_registration_deadline] }
it 'renders a :register template' do
register
@ -77,7 +81,7 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
end
context 'when runner cannot be registered after creation' do
let_it_be(:new_runner) { runner }
let(:runner_traits) { [:created_after_registration_deadline] }
it 'returns :not_found' do
register
@ -93,9 +97,7 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
end
context 'when runner can be registered after creation' do
let_it_be(:new_runner) do
create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user)
end
let(:runner_traits) { [:created_before_registration_deadline] }
it 'returns :not_found' do
register

View File

@ -80,6 +80,14 @@ FactoryBot.define do
created_at { 0.001.seconds.after(Ci::Runner.stale_deadline) }
end
trait :created_before_registration_deadline do
created_at { 0.001.seconds.after(Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago) }
end
trait :created_after_registration_deadline do
created_at { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago }
end
trait :instance do
runner_type { :instance_type }
end

View File

@ -130,3 +130,14 @@ export const mockPipelineVariablesPermissions = (value) => ({
},
},
});
export const minimumRoleResponse = {
data: {
project: {
id: mockId,
ciCdSettings: {
pipelineVariablesMinimumOverrideRole: 'developer',
},
},
},
};

View File

@ -29,7 +29,6 @@ describe('Pipeline Variables Permissions Mixin', () => {
const ROLE_NO_ONE = 'no_one_allowed';
const ROLE_DEVELOPER = 'developer';
const ROLE_MAINTAINER = 'maintainer';
const ROLE_OWNER = 'owner';
const defaultProvide = {
userRole: ROLE_DEVELOPER,
@ -67,6 +66,24 @@ describe('Pipeline Variables Permissions Mixin', () => {
const findErrorState = () => wrapper.findByTestId('error-state');
describe('on load', () => {
describe('provide data', () => {
beforeEach(() => {
minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse());
});
it('uses `projectPath` for the query if provided', async () => {
await createComponent();
expect(minimumRoleHandler).toHaveBeenCalledWith({ fullPath: 'project/path' });
});
it('uses `fullPath` for the query if provided', async () => {
await createComponent({
provide: { fullPath: 'project/another/path', projectPath: undefined },
});
expect(minimumRoleHandler).toHaveBeenCalledWith({ fullPath: 'project/another/path' });
});
});
describe('when settings query is successful', () => {
beforeEach(async () => {
minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse());
@ -110,11 +127,12 @@ describe('Pipeline Variables Permissions Mixin', () => {
describe('permissions calculations based on user roles', () => {
it.each`
scenario | userRole | minimumRole | isAuthorized
${'user role is lower than minimum role'} | ${ROLE_DEVELOPER} | ${ROLE_MAINTAINER} | ${false}
${'user role is equal to minimum role'} | ${ROLE_MAINTAINER} | ${ROLE_MAINTAINER} | ${true}
${'user role is higher than minimum role'} | ${ROLE_OWNER} | ${ROLE_MAINTAINER} | ${true}
${'minimum role is no_one_allowed'} | ${ROLE_OWNER} | ${ROLE_NO_ONE} | ${false}
scenario | userRole | minimumRole | isAuthorized
${'user role is lower than minimum role'} | ${'Developer'} | ${ROLE_MAINTAINER} | ${false}
${'user role is equal to minimum role'} | ${'Maintainer'} | ${ROLE_MAINTAINER} | ${true}
${'user role is higher than minimum role'} | ${'Owner'} | ${ROLE_MAINTAINER} | ${true}
${'user role is higher than minimum role'} | ${''} | ${ROLE_MAINTAINER} | ${false}
${'minimum role is no_one_allowed'} | ${'Owner'} | ${ROLE_NO_ONE} | ${false}
`(
'when $scenario, authorization is $isAuthorized',
async ({ userRole, minimumRole, isAuthorized }) => {

View File

@ -48,18 +48,18 @@ describe('Pipeline New Form', () => {
provide: {
identityVerificationRequired: true,
identityVerificationPath: '/test',
projectPath,
userRole: 'Maintainer',
},
propsData: {
projectId: mockProjectId,
pipelinesPath,
pipelinesEditorPath,
canViewPipelineEditor: true,
projectPath,
defaultBranch,
refParam: defaultBranch,
settingsLink: '',
maxWarnings: 25,
isMaintainer: false,
...props,
},
});

View File

@ -15,6 +15,8 @@ import PipelineNewForm from '~/ci/pipeline_new/components/pipeline_new_form.vue'
import PipelineVariablesForm from '~/ci/pipeline_new/components/pipeline_variables_form.vue';
import pipelineCreateMutation from '~/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql';
import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue';
import { mockPipelineVariablesPermissions } from 'jest/ci/job_details/mock_data';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mockProjectId, mockPipelineConfigButtonText } from '../mock_data';
Vue.directive('safe-html', {
@ -36,7 +38,6 @@ const defaultProps = {
pipelinesPath: '/root/project/-/pipelines',
pipelinesEditorPath: '/root/project/-/ci/editor',
canViewPipelineEditor: true,
projectPath: '/root/project/-/pipelines/config_variables',
defaultBranch: 'main',
refParam: 'main',
settingsLink: '',
@ -44,6 +45,13 @@ const defaultProps = {
isMaintainer: true,
};
const defaultProvide = {
projectPath: '/root/project/-/pipelines/config_variables',
userRole: 'Maintainer',
identityVerificationRequired: true,
identityVerificationPath: '/test',
};
describe('Pipeline New Form', () => {
let wrapper;
let mock;
@ -74,14 +82,14 @@ describe('Pipeline New Form', () => {
mountFn = shallowMountExtended,
stubs = {},
ciInputsForPipelines = false,
pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
} = {}) => {
const handlers = [[pipelineCreateMutation, pipelineCreateMutationHandler]];
mockApollo = createMockApollo(handlers);
wrapper = mountFn(PipelineNewForm, {
apolloProvider: mockApollo,
provide: {
identityVerificationRequired: true,
identityVerificationPath: '/test',
...defaultProvide,
glFeatures: {
ciInputsForPipelines,
},
@ -91,6 +99,7 @@ describe('Pipeline New Form', () => {
...props,
},
stubs,
mixins: [glFeatureFlagMixin(), pipelineVariablesPermissionsMixin],
});
await waitForPromises();
@ -147,33 +156,47 @@ describe('Pipeline New Form', () => {
});
describe('Pipeline variables form', () => {
beforeEach(async () => {
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
await createComponentWithApollo();
});
describe('when user has permission to view variables', () => {
beforeEach(async () => {
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
await createComponentWithApollo();
});
it('renders the pipeline variables form component', () => {
expect(findPipelineVariablesForm().exists()).toBe(true);
expect(findPipelineVariablesForm().props()).toMatchObject({
isMaintainer: true,
projectPath: defaultProps.projectPath,
refParam: `refs/heads/${defaultProps.refParam}`,
settingsLink: '',
it('renders the pipeline variables form component', () => {
expect(findPipelineVariablesForm().exists()).toBe(true);
expect(findPipelineVariablesForm().props()).toMatchObject({
isMaintainer: true,
refParam: `refs/heads/${defaultProps.refParam}`,
settingsLink: '',
});
});
it('passes variables to the create mutation', async () => {
const variables = [{ key: 'TEST_VAR', value: 'test_value' }];
findPipelineVariablesForm().vm.$emit('variables-updated', variables);
findForm().vm.$emit('submit', dummySubmitEvent);
await waitForPromises();
expect(pipelineCreateMutationHandler).toHaveBeenCalledWith({
input: {
ref: 'main',
projectPath: '/root/project/-/pipelines/config_variables',
variables,
},
});
});
});
it('passes variables to the create mutation', async () => {
const variables = [{ key: 'TEST_VAR', value: 'test_value' }];
findPipelineVariablesForm().vm.$emit('variables-updated', variables);
findForm().vm.$emit('submit', dummySubmitEvent);
await waitForPromises();
describe('when user does not have permission to view variables', () => {
beforeEach(async () => {
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
await createComponentWithApollo({
pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
});
});
expect(pipelineCreateMutationHandler).toHaveBeenCalledWith({
input: {
ref: 'main',
projectPath: '/root/project/-/pipelines/config_variables',
variables,
},
it('does not render the pipeline variables form component', () => {
expect(findPipelineVariablesForm().exists()).toBe(false);
});
});
});

View File

@ -27,11 +27,12 @@ describe('PipelineVariablesForm', () => {
const defaultProps = {
isMaintainer: true,
projectPath: 'group/project',
refParam: 'refs/heads/feature',
settingsLink: 'link/to/settings',
};
const defaultProvide = { projectPath: 'group/project' };
const configVariablesWithOptions = [
{
key: 'VAR_WITH_OPTIONS',
@ -62,6 +63,7 @@ describe('PipelineVariablesForm', () => {
wrapper = shallowMountExtended(PipelineVariablesForm, {
apolloProvider: mockApollo,
propsData: { ...defaultProps, ...props },
provide: { ...defaultProvide },
});
await waitForPromises();
@ -146,7 +148,7 @@ describe('PipelineVariablesForm', () => {
await createComponent();
expect(mockCiConfigVariables).toHaveBeenCalledWith({
fullPath: defaultProps.projectPath,
fullPath: defaultProvide.projectPath,
ref: defaultProps.refParam,
});
});

View File

@ -1,11 +1,9 @@
import MockAdapter from 'axios-mock-adapter';
import { GlForm, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { createAlert } from '~/alert';
import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue';
@ -16,7 +14,12 @@ import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
import createPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql';
import updatePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql';
import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql';
import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql';
import {
mockPipelineVariablesPermissions,
minimumRoleResponse,
} from 'jest/ci/job_details/mock_data';
import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers';
import {
createScheduleMutationResponse,
@ -51,6 +54,16 @@ describe('Pipeline schedules form', () => {
const cron = '';
const dailyLimit = '';
const defaultProvide = {
fullPath: 'gitlab-org/gitlab',
projectId,
defaultBranch,
dailyLimit,
settingsLink: '',
schedulesPath: '/root/ci-project/-/pipeline_schedules',
userRole: 'maintainer',
};
const querySuccessHandler = jest.fn().mockResolvedValue(mockSinglePipelineScheduleNode);
const queryFailedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
@ -59,27 +72,32 @@ describe('Pipeline schedules form', () => {
const updateMutationHandlerSuccess = jest.fn().mockResolvedValue(updateScheduleMutationResponse);
const updateMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const minimumRoleHandler = jest.fn().mockResolvedValue(minimumRoleResponse);
const createMockApolloProvider = (
requestHandlers = [[createPipelineScheduleMutation, createMutationHandlerSuccess]],
requestHandlers = [
[getPipelineVariablesMinimumOverrideRoleQuery, minimumRoleHandler],
[createPipelineScheduleMutation, createMutationHandlerSuccess],
],
) => {
return createMockApollo(requestHandlers);
};
const createComponent = (mountFn = shallowMountExtended, editing = false, requestHandlers) => {
wrapper = mountFn(PipelineSchedulesForm, {
const createComponent = ({
editing = false,
pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
requestHandlers,
} = {}) => {
wrapper = shallowMountExtended(PipelineSchedulesForm, {
propsData: {
timezoneData: timezoneDataFixture,
refParam: 'master',
editing,
},
provide: {
fullPath: 'gitlab-org/gitlab',
projectId,
defaultBranch,
dailyLimit,
settingsLink: '',
schedulesPath: '/root/ci-project/-/pipeline_schedules',
...defaultProvide,
},
mixins: [pipelineVariablesPermissionsMixin],
apolloProvider: createMockApolloProvider(requestHandlers),
});
};
@ -95,19 +113,21 @@ describe('Pipeline schedules form', () => {
const findPipelineVariables = () => wrapper.findComponent(PipelineVariablesFormGroup);
describe('Form elements', () => {
beforeEach(() => {
createComponent();
});
it('displays form', () => {
createComponent();
expect(findForm().exists()).toBe(true);
});
it('displays the description input', () => {
createComponent();
expect(findDescription().exists()).toBe(true);
});
it('displays the interval pattern component', () => {
createComponent();
const intervalPattern = findIntervalComponent();
expect(intervalPattern.exists()).toBe(true);
@ -119,6 +139,8 @@ describe('Pipeline schedules form', () => {
});
it('displays the Timezone dropdown', () => {
createComponent();
const timezoneDropdown = findTimezoneDropdown();
expect(timezoneDropdown.exists()).toBe(true);
@ -130,6 +152,8 @@ describe('Pipeline schedules form', () => {
});
it('displays the branch/tag selector', () => {
createComponent();
const refSelector = findRefSelector();
expect(refSelector.exists()).toBe(true);
@ -144,7 +168,9 @@ describe('Pipeline schedules form', () => {
});
});
it('displays variable list', () => {
it('displays variable list when the user has permissions', () => {
createComponent();
expect(findPipelineVariables().exists()).toBe(true);
expect(findPipelineVariables().props()).toEqual({
initialVariables: [],
@ -152,7 +178,17 @@ describe('Pipeline schedules form', () => {
});
});
it('does not display variable list when the user has no permissions', () => {
createComponent({
pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
});
expect(findPipelineVariables().exists()).toBe(false);
});
it('displays the submit and cancel buttons', () => {
createComponent();
expect(findSubmitButton().exists()).toBe(true);
expect(findCancelButton().exists()).toBe(true);
expect(findCancelButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules');
@ -167,9 +203,10 @@ describe('Pipeline schedules form', () => {
`(
'button text is $expectedText when editing is $editing',
async ({ editing, expectedText }) => {
createComponent(shallowMountExtended, editing, [
[getPipelineSchedulesQuery, querySuccessHandler],
]);
createComponent({
editing,
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
});
await waitForPromises();
@ -201,22 +238,12 @@ describe('Pipeline schedules form', () => {
});
describe('schedule creation success', () => {
let mock;
beforeEach(() => {
// mock is needed when we fully mount
// downstream components request needs to be mocked
mock = new MockAdapter(axios);
createComponent(mountExtended);
});
afterEach(() => {
mock.restore();
createComponent();
});
it('creates pipeline schedule', async () => {
findDescription().element.value = 'My schedule';
findDescription().trigger('change');
findDescription().vm.$emit('input', 'My schedule');
findTimezoneDropdown().vm.$emit('input', {
formattedTimezone: '[UTC-4] Eastern Time (US & Canada)',
@ -261,9 +288,9 @@ describe('Pipeline schedules form', () => {
describe('schedule creation failure', () => {
beforeEach(() => {
createComponent(shallowMountExtended, false, [
[createPipelineScheduleMutation, createMutationHandlerFailed],
]);
createComponent({
requestHandlers: [[createPipelineScheduleMutation, createMutationHandlerFailed]],
});
});
it('shows error for failed pipeline schedule creation', async () => {
@ -279,20 +306,11 @@ describe('Pipeline schedules form', () => {
});
describe('Schedule editing', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('shows loading state when editing', async () => {
createComponent(shallowMountExtended, true, [
[getPipelineSchedulesQuery, querySuccessHandler],
]);
createComponent({
editing: true,
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
});
expect(findLoadingIcon().exists()).toBe(true);
@ -302,9 +320,10 @@ describe('Pipeline schedules form', () => {
});
it('provides variables to the variable list', async () => {
createComponent(shallowMountExtended, true, [
[getPipelineSchedulesQuery, querySuccessHandler],
]);
createComponent({
editing: true,
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
});
await waitForPromises();
expect(findPipelineVariables().props('editing')).toBe(true);
@ -313,26 +332,30 @@ describe('Pipeline schedules form', () => {
describe('schedule fetch success', () => {
it('fetches schedule and sets form data correctly', async () => {
createComponent(mountExtended, true, [[getPipelineSchedulesQuery, querySuccessHandler]]);
createComponent({
editing: true,
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
});
expect(querySuccessHandler).toHaveBeenCalled();
await waitForPromises();
expect(findDescription().element.value).toBe(schedule.description);
expect(findDescription().props('value')).toBe(schedule.description);
expect(findIntervalComponent().props('initialCronInterval')).toBe(schedule.cron);
expect(findTimezoneDropdown().props('value')).toBe(schedule.cronTimezone);
expect(findRefSelector().props('value')).toBe(schedule.ref);
expect(findPipelineVariables().props('initialVariables')).toHaveLength(3);
expect(findPipelineVariables().props('initialVariables')).toHaveLength(2);
expect(findPipelineVariables().props('initialVariables')[0].key).toBe(variables[0].key);
expect(findPipelineVariables().props('initialVariables')[1].key).toBe(variables[1].key);
});
});
it('schedule fetch failure', async () => {
createComponent(shallowMountExtended, true, [
[getPipelineSchedulesQuery, queryFailedHandler],
]);
createComponent({
editing: true,
requestHandlers: [[getPipelineSchedulesQuery, queryFailedHandler]],
});
await waitForPromises();
@ -342,15 +365,17 @@ describe('Pipeline schedules form', () => {
});
it('edit schedule success', async () => {
createComponent(mountExtended, true, [
[getPipelineSchedulesQuery, querySuccessHandler],
[updatePipelineScheduleMutation, updateMutationHandlerSuccess],
]);
createComponent({
editing: true,
requestHandlers: [
[getPipelineSchedulesQuery, querySuccessHandler],
[updatePipelineScheduleMutation, updateMutationHandlerSuccess],
],
});
await waitForPromises();
findDescription().element.value = 'Updated schedule';
findDescription().trigger('change');
findDescription().vm.$emit('input', 'Updated schedule');
findIntervalComponent().vm.$emit('cronValue', '0 22 16 * *');
@ -405,10 +430,13 @@ describe('Pipeline schedules form', () => {
});
it('edit schedule failure', async () => {
createComponent(shallowMountExtended, true, [
[getPipelineSchedulesQuery, querySuccessHandler],
[updatePipelineScheduleMutation, updateMutationHandlerFailed],
]);
createComponent({
editing: true,
requestHandlers: [
[getPipelineSchedulesQuery, querySuccessHandler],
[updatePipelineScheduleMutation, updateMutationHandlerFailed],
],
});
await waitForPromises();

View File

@ -7,7 +7,7 @@ import personalProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your
import membershipProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/membership_projects.query.graphql.json';
import contributedProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/contributed_projects.query.graphql.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TabView from '~/projects/your_work/components/tab_view.vue';
import TabView from '~/groups_projects/components/tab_view.vue';
import { formatProjects } from '~/projects/your_work/utils';
import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import ProjectsListEmptyState from '~/vue_shared/components/projects_list/projects_list_empty_state.vue';
@ -19,9 +19,11 @@ import {
MEMBER_TAB,
STARRED_TAB,
INACTIVE_TAB,
} from '~/projects/your_work/constants';
import {
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '~/projects/your_work/constants';
} from '~/groups_projects/constants';
import { FILTERED_SEARCH_TERM_KEY } from '~/projects/filtered_search_and_sort/constants';
import { ACCESS_LEVEL_OWNER_INTEGER, ACCESS_LEVEL_OWNER_STRING } from '~/access_level/constants';
import { TIMESTAMP_TYPE_CREATED_AT } from '~/vue_shared/components/resource_lists/constants';

View File

@ -0,0 +1,551 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { GlBadge, GlTabs, GlFilteredSearchToken } from '@gitlab/ui';
import projectCountsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/project_counts.query.graphql.json';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
import TabView from '~/groups_projects/components/tab_view.vue';
import { createRouter } from '~/projects/your_work';
import { stubComponent } from 'helpers/stub_component';
import {
ROOT_ROUTE_NAME,
DASHBOARD_ROUTE_NAME,
PROJECTS_DASHBOARD_ROUTE_NAME,
PROJECT_DASHBOARD_TABS,
CONTRIBUTED_TAB,
STARRED_TAB,
PERSONAL_TAB,
MEMBER_TAB,
INACTIVE_TAB,
} from '~/projects/your_work/constants';
import {
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '~/groups_projects/constants';
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
import {
SORT_OPTIONS,
SORT_OPTION_CREATED,
SORT_OPTION_UPDATED,
SORT_DIRECTION_DESC,
SORT_DIRECTION_ASC,
FILTERED_SEARCH_TERM_KEY,
FILTERED_SEARCH_NAMESPACE,
} from '~/projects/filtered_search_and_sort/constants';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
import userPreferencesUpdateMutation from '~/groups_projects/graphql/mutations/user_preferences_update.mutation.graphql';
import { createAlert } from '~/alert';
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
} from '~/vue_shared/components/resource_lists/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { programmingLanguages } from './mock_data';
jest.mock('~/alert');
jest.mock('~/sentry/sentry_browser_wrapper');
Vue.use(VueRouter);
Vue.use(VueApollo);
const defaultRoute = {
name: ROOT_ROUTE_NAME,
};
const defaultProvide = {
initialSort: 'created_desc',
programmingLanguages,
};
const searchTerm = 'foo bar';
const mockEndCursor = 'mockEndCursor';
const mockStartCursor = 'mockStartCursor';
describe('TabsWithList', () => {
let wrapper;
let router;
let mockApollo;
const successHandler = jest.fn().mockResolvedValue(projectCountsGraphQlResponse);
const userPreferencesUpdateSuccessHandler = jest.fn().mockResolvedValue({
data: {
userPreferencesUpdate: {
userPreferences: {
projectsSort: 'NAME_DESC',
},
},
},
});
const createComponent = async ({
provide = {},
projectsCountHandler = successHandler,
userPreferencesUpdateHandler = userPreferencesUpdateSuccessHandler,
route = defaultRoute,
} = {}) => {
mockApollo = createMockApollo([
[projectCountsQuery, projectsCountHandler],
[userPreferencesUpdateMutation, userPreferencesUpdateHandler],
]);
router = createRouter();
await router.push(route);
wrapper = mountExtended(TabsWithList, {
apolloProvider: mockApollo,
router,
stubs: {
TabView: stubComponent(TabView),
},
provide: { ...defaultProvide, ...provide },
});
};
const findGlTabs = () => wrapper.findComponent(GlTabs);
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
const findTabByName = (name) =>
wrapper.findAllByRole('tab').wrappers.find((tab) => tab.text().includes(name));
const getTabCount = (tabName) =>
extendedWrapper(findTabByName(tabName)).findByTestId('tab-counter-badge').text();
const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
const findTabView = () => wrapper.findComponent(TabView);
afterEach(() => {
router = null;
mockApollo = null;
});
describe('template', () => {
describe('when project counts are loading', () => {
it('does not show count badges', async () => {
await createComponent();
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
});
});
describe('when project counts are successfully retrieved', () => {
beforeEach(async () => {
await createComponent();
await waitForPromises();
});
it('shows count badges', () => {
expect(getTabCount('Contributed')).toBe('2');
expect(getTabCount('Starred')).toBe('0');
expect(getTabCount('Personal')).toBe('0');
expect(getTabCount('Member')).toBe('2');
expect(getTabCount('Inactive')).toBe('0');
});
});
describe('when project counts are not successfully retrieved', () => {
const error = new Error();
beforeEach(async () => {
await createComponent({ projectsCountHandler: jest.fn().mockRejectedValue(error) });
await waitForPromises();
});
it('displays error alert', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred loading the project counts.',
error,
captureError: true,
});
});
it('does not show count badges', () => {
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
});
});
it('defaults to Contributed tab as active', () => {
expect(findActiveTab().text()).toContain('Contributed');
});
it('renders filtered search bar with correct props', async () => {
await createComponent();
expect(findFilteredSearchAndSort().props()).toMatchObject({
filteredSearchTokens: [
{
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
icon: 'code',
title: 'Language',
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [
{ value: '5', title: 'CSS' },
{ value: '8', title: 'CoffeeScript' },
],
},
{
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
icon: 'user',
title: 'Role',
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: [
{
value: '50',
title: 'Owner',
},
],
},
],
filteredSearchQuery: {},
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
sortOptions: SORT_OPTIONS,
activeSortOption: SORT_OPTION_CREATED,
isAscending: false,
});
});
describe('when filtered search bar is submitted', () => {
beforeEach(async () => {
await createComponent();
findFilteredSearchAndSort().vm.$emit('filter', {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
});
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({ [FILTERED_SEARCH_TERM_KEY]: searchTerm });
});
});
describe('when sort is changed', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
},
},
});
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_DESC}`,
});
});
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
input: { projectsSort: 'LATEST_ACTIVITY_DESC' },
});
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe('when sort direction is changed', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
},
},
});
findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_ASC}`,
});
});
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
input: { projectsSort: 'CREATED_ASC' },
});
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
});
describe('when `userPreferencesUpdate` mutation fails', () => {
const error = new Error();
beforeEach(async () => {
await createComponent({ userPreferencesUpdateHandler: jest.fn().mockRejectedValue(error) });
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
await waitForPromises();
});
it('captures error in Sentry', () => {
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
});
describe.each`
name | expectedTab
${ROOT_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${PROJECTS_DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${CONTRIBUTED_TAB.value} | ${CONTRIBUTED_TAB}
${STARRED_TAB.value} | ${STARRED_TAB}
${PERSONAL_TAB.value} | ${PERSONAL_TAB}
${MEMBER_TAB.value} | ${MEMBER_TAB}
${INACTIVE_TAB.value} | ${INACTIVE_TAB}
`('onMount when route name is $name', ({ name, expectedTab }) => {
const query = {
sort: 'name_desc',
[FILTERED_SEARCH_TERM_KEY]: 'foo',
[FILTERED_SEARCH_TOKEN_LANGUAGE]: '8',
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
[QUERY_PARAM_START_CURSOR]: mockStartCursor,
};
beforeEach(async () => {
await createComponent({
route: { name, query },
});
});
it('initializes to the correct tab', () => {
expect(findActiveTab().text()).toContain(expectedTab.text);
});
it('renders `TabView` component and passes `tab` prop', () => {
expect(findTabView().props('tab')).toMatchObject(expectedTab);
});
it('passes sorting, filtering, and pagination props', () => {
expect(findTabView().props()).toMatchObject({
sort: query.sort,
filters: {
[FILTERED_SEARCH_TERM_KEY]: query[FILTERED_SEARCH_TERM_KEY],
[FILTERED_SEARCH_TOKEN_LANGUAGE]: query[FILTERED_SEARCH_TOKEN_LANGUAGE],
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: query[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
},
endCursor: mockEndCursor,
startCursor: mockStartCursor,
});
});
});
describe('when sort query param is invalid', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
sort: 'foo_bar',
},
},
});
});
it('falls back to initial sort', () => {
expect(findTabView().props()).toMatchObject({
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_DESC}`,
});
});
});
describe('when sort query param and initial sort are invalid', () => {
beforeEach(async () => {
await createComponent({
provide: { initialSort: 'foo_bar' },
route: {
...defaultRoute,
query: {
sort: 'foo_bar',
},
},
});
});
it('falls back to updated in ascending order', () => {
expect(findTabView().props()).toMatchObject({
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`,
});
});
});
describe('onTabUpdate', () => {
describe('when tab is already active', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('does not push new route', async () => {
findGlTabs().vm.$emit('input', 0);
await nextTick();
expect(router.push).not.toHaveBeenCalled();
});
});
describe('when tab is a valid tab', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('pushes new route correctly', async () => {
findGlTabs().vm.$emit('input', 2);
await nextTick();
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[2].value });
});
});
describe('when tab is an invalid tab', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('pushes new route with default Contributed tab', async () => {
findGlTabs().vm.$emit('input', 100);
await nextTick();
expect(router.push).toHaveBeenCalledWith({ name: CONTRIBUTED_TAB.value });
});
});
describe('when gon.relative_url_root is set', () => {
beforeEach(async () => {
gon.relative_url_root = '/gitlab';
await createComponent();
router.push = jest.fn();
});
it('pushes new route correctly and respects relative url', async () => {
findGlTabs().vm.$emit('input', 3);
await nextTick();
if (router.options.base) {
// Vue router 3
expect(router.options.base).toBe('/gitlab');
} else {
// Vue router 4
expect(router.currentRoute.href).toBe('/gitlab/');
}
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[3].value });
});
});
});
describe('when page is changed', () => {
describe('when going to next page', () => {
beforeEach(async () => {
await createComponent({
route: defaultRoute,
});
await nextTick();
findTabView().vm.$emit('page-change', {
endCursor: mockEndCursor,
startCursor: null,
hasPreviousPage: true,
});
await waitForPromises();
});
it('sets `end_cursor` query string', () => {
expect(router.currentRoute.query).toMatchObject({
end_cursor: mockEndCursor,
});
});
});
describe('when going to previous page', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
start_cursor: mockStartCursor,
end_cursor: mockEndCursor,
},
},
});
await nextTick();
findTabView().vm.$emit('page-change', {
endCursor: null,
startCursor: mockStartCursor,
hasPreviousPage: true,
});
});
it('sets `start_cursor` query string', () => {
expect(router.currentRoute.query).toMatchObject({
start_cursor: mockStartCursor,
});
});
});
});
describe.each`
sort | expectedTimestampType
${'name_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'name_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'created_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'created_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'latest_activity_asc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
${'latest_activity_desc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
${'stars_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'stars_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
`('when sort is $sort', ({ sort, expectedTimestampType }) => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
sort,
},
},
});
});
it('correctly passes timestampType prop to TabView component', () => {
expect(findTabView().props('timestampType')).toBe(expectedTimestampType);
});
});
});

View File

@ -1,549 +1,19 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { GlBadge, GlTabs, GlFilteredSearchToken } from '@gitlab/ui';
import projectCountsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/project_counts.query.graphql.json';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import YourWorkProjectsApp from '~/projects/your_work/components/app.vue';
import TabView from '~/projects/your_work/components/tab_view.vue';
import { createRouter } from '~/projects/your_work';
import { stubComponent } from 'helpers/stub_component';
import {
ROOT_ROUTE_NAME,
DASHBOARD_ROUTE_NAME,
PROJECTS_DASHBOARD_ROUTE_NAME,
PROJECT_DASHBOARD_TABS,
CONTRIBUTED_TAB,
STARRED_TAB,
PERSONAL_TAB,
MEMBER_TAB,
INACTIVE_TAB,
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
} from '~/projects/your_work/constants';
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
import {
SORT_OPTIONS,
SORT_OPTION_CREATED,
SORT_OPTION_UPDATED,
SORT_DIRECTION_DESC,
SORT_DIRECTION_ASC,
FILTERED_SEARCH_TERM_KEY,
FILTERED_SEARCH_NAMESPACE,
} from '~/projects/filtered_search_and_sort/constants';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
import userPreferencesUpdateMutation from '~/projects/your_work/graphql/mutations/user_preferences_update.mutation.graphql';
import { createAlert } from '~/alert';
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
} from '~/vue_shared/components/resource_lists/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { programmingLanguages } from './mock_data';
jest.mock('~/alert');
jest.mock('~/sentry/sentry_browser_wrapper');
Vue.use(VueRouter);
Vue.use(VueApollo);
const defaultRoute = {
name: ROOT_ROUTE_NAME,
};
const defaultProvide = {
initialSort: 'created_desc',
programmingLanguages,
};
const searchTerm = 'foo bar';
const mockEndCursor = 'mockEndCursor';
const mockStartCursor = 'mockStartCursor';
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
describe('YourWorkProjectsApp', () => {
let wrapper;
let router;
let mockApollo;
const successHandler = jest.fn().mockResolvedValue(projectCountsGraphQlResponse);
const userPreferencesUpdateSuccessHandler = jest.fn().mockResolvedValue({
data: {
userPreferencesUpdate: {
userPreferences: {
projectsSort: 'NAME_DESC',
},
},
},
});
const createComponent = async ({
provide = {},
projectsCountHandler = successHandler,
userPreferencesUpdateHandler = userPreferencesUpdateSuccessHandler,
route = defaultRoute,
} = {}) => {
mockApollo = createMockApollo([
[projectCountsQuery, projectsCountHandler],
[userPreferencesUpdateMutation, userPreferencesUpdateHandler],
]);
router = createRouter();
await router.push(route);
wrapper = mountExtended(YourWorkProjectsApp, {
apolloProvider: mockApollo,
router,
stubs: {
TabView: stubComponent(TabView),
},
provide: { ...defaultProvide, ...provide },
});
const createComponent = () => {
wrapper = shallowMountExtended(YourWorkProjectsApp);
};
const findGlTabs = () => wrapper.findComponent(GlTabs);
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
const findTabByName = (name) =>
wrapper.findAllByRole('tab').wrappers.find((tab) => tab.text().includes(name));
const getTabCount = (tabName) =>
extendedWrapper(findTabByName(tabName)).findByTestId('tab-counter-badge').text();
const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
const findTabView = () => wrapper.findComponent(TabView);
afterEach(() => {
router = null;
mockApollo = null;
beforeEach(() => {
createComponent();
});
describe('template', () => {
describe('when project counts are loading', () => {
it('does not show count badges', async () => {
await createComponent();
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
});
});
describe('when project counts are successfully retrieved', () => {
beforeEach(async () => {
await createComponent();
await waitForPromises();
});
it('shows count badges', () => {
expect(getTabCount('Contributed')).toBe('2');
expect(getTabCount('Starred')).toBe('0');
expect(getTabCount('Personal')).toBe('0');
expect(getTabCount('Member')).toBe('2');
expect(getTabCount('Inactive')).toBe('0');
});
});
describe('when project counts are not successfully retrieved', () => {
const error = new Error();
beforeEach(async () => {
await createComponent({ projectsCountHandler: jest.fn().mockRejectedValue(error) });
await waitForPromises();
});
it('displays error alert', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred loading the project counts.',
error,
captureError: true,
});
});
it('does not show count badges', () => {
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
});
});
it('defaults to Contributed tab as active', () => {
expect(findActiveTab().text()).toContain('Contributed');
});
it('renders filtered search bar with correct props', async () => {
await createComponent();
expect(findFilteredSearchAndSort().props()).toMatchObject({
filteredSearchTokens: [
{
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
icon: 'code',
title: 'Language',
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [
{ value: '5', title: 'CSS' },
{ value: '8', title: 'CoffeeScript' },
],
},
{
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
icon: 'user',
title: 'Role',
token: GlFilteredSearchToken,
unique: true,
operators: OPERATORS_IS,
options: [
{
value: '50',
title: 'Owner',
},
],
},
],
filteredSearchQuery: {},
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
sortOptions: SORT_OPTIONS,
activeSortOption: SORT_OPTION_CREATED,
isAscending: false,
});
});
describe('when filtered search bar is submitted', () => {
beforeEach(async () => {
await createComponent();
findFilteredSearchAndSort().vm.$emit('filter', {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
});
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({ [FILTERED_SEARCH_TERM_KEY]: searchTerm });
});
});
describe('when sort is changed', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
},
},
});
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_DESC}`,
});
});
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
input: { projectsSort: 'LATEST_ACTIVITY_DESC' },
});
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe('when sort direction is changed', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
},
},
});
findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
await waitForPromises();
});
it('updates query string', () => {
expect(router.currentRoute.query).toEqual({
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_ASC}`,
});
});
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
input: { projectsSort: 'CREATED_ASC' },
});
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
});
describe('when `userPreferencesUpdate` mutation fails', () => {
const error = new Error();
beforeEach(async () => {
await createComponent({ userPreferencesUpdateHandler: jest.fn().mockRejectedValue(error) });
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
await waitForPromises();
});
it('captures error in Sentry', () => {
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
});
describe.each`
name | expectedTab
${ROOT_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${PROJECTS_DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
${CONTRIBUTED_TAB.value} | ${CONTRIBUTED_TAB}
${STARRED_TAB.value} | ${STARRED_TAB}
${PERSONAL_TAB.value} | ${PERSONAL_TAB}
${MEMBER_TAB.value} | ${MEMBER_TAB}
${INACTIVE_TAB.value} | ${INACTIVE_TAB}
`('onMount when route name is $name', ({ name, expectedTab }) => {
const query = {
sort: 'name_desc',
[FILTERED_SEARCH_TERM_KEY]: 'foo',
[FILTERED_SEARCH_TOKEN_LANGUAGE]: '8',
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER,
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
[QUERY_PARAM_START_CURSOR]: mockStartCursor,
};
beforeEach(async () => {
await createComponent({
route: { name, query },
});
});
it('initializes to the correct tab', () => {
expect(findActiveTab().text()).toContain(expectedTab.text);
});
it('renders `TabView` component and passes `tab` prop', () => {
expect(findTabView().props('tab')).toMatchObject(expectedTab);
});
it('passes sorting, filtering, and pagination props', () => {
expect(findTabView().props()).toMatchObject({
sort: query.sort,
filters: {
[FILTERED_SEARCH_TERM_KEY]: query[FILTERED_SEARCH_TERM_KEY],
[FILTERED_SEARCH_TOKEN_LANGUAGE]: query[FILTERED_SEARCH_TOKEN_LANGUAGE],
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: query[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
},
endCursor: mockEndCursor,
startCursor: mockStartCursor,
});
});
});
describe('when sort query param is invalid', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
sort: 'foo_bar',
},
},
});
});
it('falls back to initial sort', () => {
expect(findTabView().props()).toMatchObject({
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_DESC}`,
});
});
});
describe('when sort query param and initial sort are invalid', () => {
beforeEach(async () => {
await createComponent({
provide: { initialSort: 'foo_bar' },
route: {
...defaultRoute,
query: {
sort: 'foo_bar',
},
},
});
});
it('falls back to updated in ascending order', () => {
expect(findTabView().props()).toMatchObject({
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`,
});
});
});
describe('onTabUpdate', () => {
describe('when tab is already active', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('does not push new route', async () => {
findGlTabs().vm.$emit('input', 0);
await nextTick();
expect(router.push).not.toHaveBeenCalled();
});
});
describe('when tab is a valid tab', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('pushes new route correctly', async () => {
findGlTabs().vm.$emit('input', 2);
await nextTick();
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[2].value });
});
});
describe('when tab is an invalid tab', () => {
beforeEach(async () => {
await createComponent();
router.push = jest.fn();
});
it('pushes new route with default Contributed tab', async () => {
findGlTabs().vm.$emit('input', 100);
await nextTick();
expect(router.push).toHaveBeenCalledWith({ name: CONTRIBUTED_TAB.value });
});
});
describe('when gon.relative_url_root is set', () => {
beforeEach(async () => {
gon.relative_url_root = '/gitlab';
await createComponent();
router.push = jest.fn();
});
it('pushes new route correctly and respects relative url', async () => {
findGlTabs().vm.$emit('input', 3);
await nextTick();
if (router.options.base) {
// Vue router 3
expect(router.options.base).toBe('/gitlab');
} else {
// Vue router 4
expect(router.currentRoute.href).toBe('/gitlab/');
}
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[3].value });
});
});
});
describe('when page is changed', () => {
describe('when going to next page', () => {
beforeEach(async () => {
await createComponent({
route: defaultRoute,
});
await nextTick();
findTabView().vm.$emit('page-change', {
endCursor: mockEndCursor,
startCursor: null,
hasPreviousPage: true,
});
await waitForPromises();
});
it('sets `end_cursor` query string', () => {
expect(router.currentRoute.query).toMatchObject({
end_cursor: mockEndCursor,
});
});
});
describe('when going to previous page', () => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
start_cursor: mockStartCursor,
end_cursor: mockEndCursor,
},
},
});
await nextTick();
findTabView().vm.$emit('page-change', {
endCursor: null,
startCursor: mockStartCursor,
hasPreviousPage: true,
});
});
it('sets `start_cursor` query string', () => {
expect(router.currentRoute.query).toMatchObject({
start_cursor: mockStartCursor,
});
});
});
});
describe.each`
sort | expectedTimestampType
${'name_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'name_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'created_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'created_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'latest_activity_asc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
${'latest_activity_desc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
${'stars_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
${'stars_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
`('when sort is $sort', ({ sort, expectedTimestampType }) => {
beforeEach(async () => {
await createComponent({
route: {
...defaultRoute,
query: {
sort,
},
},
});
});
it('correctly passes timestampType prop to TabView component', () => {
expect(findTabView().props('timestampType')).toBe(expectedTimestampType);
});
it('renders TabsWithList component', () => {
expect(wrapper.findComponent(TabsWithList).exists()).toBe(true);
});
});

View File

@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemNoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteEditedText from '~/notes/components/note_edited_text.vue';
@ -17,16 +18,59 @@ describe('Work Item Note Body', () => {
});
};
beforeEach(() => {
createComponent();
});
it('should have the wrapper to show the note body', () => {
expect(findNoteBody().exists()).toBe(true);
createComponent();
expect(findNoteBody().html()).toMatchSnapshot();
});
it('should not show the edited text when the value is not present', () => {
createComponent();
expect(findNoteEditedText().exists()).toBe(false);
});
it('emits "updateNote" event to update markdown when toggling checkbox', () => {
const markdownBefore = `beginning
- [ ] one
- [ ] two
- [ ] three
end`;
const markdownAfter = `beginning
- [x] one
- [ ] two
- [ ] three
end`;
const note = {
...mockWorkItemCommentNote,
body: markdownBefore,
bodyHtml:
'<p data-sourcepos="1:1-1:9" dir="auto">beginning</p>&#x000A;<ul data-sourcepos="3:1-6:0" class="task-list" dir="auto">&#x000A;<li data-sourcepos="3:1-3:9" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> one</li>&#x000A;<li data-sourcepos="4:1-4:9" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> two</li>&#x000A;<li data-sourcepos="5:1-6:0" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> three</li>&#x000A;</ul>&#x000A;<p data-sourcepos="7:1-7:3" dir="auto">end</p>',
};
createComponent({ note });
const checkbox = wrapper.find('.task-list-item-checkbox').element;
checkbox.checked = true;
checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true }));
expect(wrapper.emitted('updateNote')).toEqual([
[{ commentText: markdownAfter, executeOptimisticResponse: false }],
]);
});
it('updates checkbox state when "isUpdating" watcher updates', async () => {
createComponent();
await nextTick();
const checkboxes = Array.from(wrapper.element.querySelectorAll('.task-list-item-checkbox'));
expect(checkboxes.every((checkbox) => checkbox.disabled === false)).toBe(true);
await wrapper.setProps({ isUpdating: true });
expect(checkboxes.every((checkbox) => checkbox.disabled === true)).toBe(true);
});
});

View File

@ -219,6 +219,28 @@ describe('Work Item Note', () => {
});
});
describe('when toggling a checkbox in a note', () => {
it('calls mutation to update the description', () => {
const commentText = `beginning
- [x] one
- [ ] two
- [ ] three
end`;
createComponent();
findNoteBody().vm.$emit('updateNote', { commentText });
expect(successHandler).toHaveBeenCalledWith({
input: {
id: mockWorkItemCommentNote.id,
body: commentText,
},
});
});
});
describe('when not editing', () => {
it('should not render a comment form', () => {
createComponent();

View File

@ -14,7 +14,31 @@ RSpec.describe Ci::PipelineSchedulesHelper, feature_category: :continuous_integr
describe '#js_pipeline_schedules_form_data' do
before do
allow(helper).to receive(:timezone_data).and_return(timezones)
allow(helper).to receive_messages(timezone_data: timezones, current_user: user)
allow(project.team).to receive(:human_max_access).with(user.id).and_return('Owner')
end
describe 'user_role' do
context 'when there is no current user' do
before do
allow(helper).to receive(:current_user).and_return(nil)
end
it 'is nil' do
expect(helper.js_pipeline_schedules_form_data(project, pipeline_schedule)[:user_role]).to be_nil
end
end
context 'when there is a current_user' do
before do
allow(helper).to receive(:current_user).and_return(user)
allow(project.team).to receive(:human_max_access).with(user.id).and_return('Developer')
end
it "returns the human readable access level that the current user has in the project" do
expect(helper.js_pipeline_schedules_form_data(project, pipeline_schedule)[:user_role]).to eq('Developer')
end
end
end
it 'returns pipeline schedule form data' do
@ -24,7 +48,8 @@ RSpec.describe Ci::PipelineSchedulesHelper, feature_category: :continuous_integr
project_id: project.id,
schedules_path: pipeline_schedules_path(project),
settings_link: project_settings_ci_cd_path(project),
timezone_data: timezones.to_json
timezone_data: timezones.to_json,
user_role: 'Owner'
})
end
end

View File

@ -131,29 +131,31 @@ RSpec.describe Ci::PipelinesHelper, feature_category: :continuous_integration do
:project_refs_endpoint,
:settings_link,
:max_warnings,
:is_maintainer
:user_role
)
end
describe 'is_maintainer' do
subject(:data) { helper.new_pipeline_data(project)[:is_maintainer] }
let_it_be(:user) { create(:user) }
before do
sign_in(user)
describe 'user_role' do
context 'when there is no current user' do
it 'is nil' do
expect(helper.new_pipeline_data(project)[:user_role]).to be_nil
end
end
context 'when user is signed in but not a maintainer' do
it { expect(subject).to be_falsy }
end
context 'when there is a current_user' do
let_it_be(:user) { create(:user) }
context 'when user is signed in with a role that is maintainer or above' do
before_all do
project.add_maintainer(user)
project.add_developer(user)
end
it { expect(subject).to be_truthy }
before do
sign_in(user)
end
it "returns the human readable access level that the current user has in the pipeline's project" do
expect(helper.new_pipeline_data(project)[:user_role]).to eq('Developer')
end
end
end
end

View File

@ -2,15 +2,16 @@
require 'spec_helper'
RSpec.describe Gitlab::Blame do
RSpec.describe Gitlab::Blame, feature_category: :source_code_management do
let_it_be(:project) { create(:project, :repository) }
let(:path) { 'files/ruby/popen.rb' }
let(:commit) { project.commit('master') }
let(:blob) { project.repository.blob_at(commit.id, path) }
let(:range) { nil }
let(:ignore_revs) { nil }
subject(:blame) { described_class.new(blob, commit, range: range) }
subject(:blame) { described_class.new(blob, commit, range: range, ignore_revs: ignore_revs) }
describe '#first_line' do
subject { blame.first_line }
@ -114,5 +115,22 @@ RSpec.describe Gitlab::Blame do
expect(subject[1][:lines]).to match_array(['Renamed as "filename"'])
end
end
context 'with ignore_revs' do
let(:ignore_revs) { true }
let(:git_blame_double) { instance_double(Gitlab::Git::Blame, each: nil) }
it 'requests for a blame with the default ignore revs file' do
expect(Gitlab::Git::Blame).to receive(:new).with(
project.repository,
commit.id,
blob.path,
range: range,
ignore_revisions_blob: 'refs/heads/master:.git-blame-ignore-revs'
).and_return(git_blame_double)
subject
end
end
end
end

View File

@ -8,8 +8,11 @@ RSpec.describe Gitlab::Git::Blame, feature_category: :source_code_management do
let(:sha) { TestEnv::BRANCH_SHA['master'] }
let(:path) { 'CONTRIBUTING.md' }
let(:range) { nil }
let(:ignore_revisions_blob) { nil }
subject(:blame) { described_class.new(repository, sha, path, range: range) }
subject(:blame) do
described_class.new(repository, sha, path, range: range, ignore_revisions_blob: ignore_revisions_blob)
end
let(:result) do
[].tap do |data|
@ -119,5 +122,25 @@ RSpec.describe Gitlab::Git::Blame, feature_category: :source_code_management do
expect(result[4]).to include(line: 'Last edit, no rename', previous_path: path)
end
end
context 'with ignore_revisions_blob' do
let(:ignore_revisions_blob) { 'reference_to_a_blob' }
let(:commit_client_double) { instance_double(Gitlab::GitalyClient::CommitService, list_commits_by_oid: []) }
before do
allow(repository).to receive(:gitaly_commit_client).and_return(commit_client_double)
end
it 'requests raw blame with ignore_revisions_blob' do
expect(commit_client_double).to receive(:raw_blame).with(
sha,
path,
range: range,
ignore_revisions_blob: ignore_revisions_blob
).and_return('')
blame
end
end
end
end

View File

@ -2248,6 +2248,54 @@ RSpec.describe Ci::Runner, type: :model, factory_default: :keep, feature_categor
end
end
describe '#registration_available?' do
subject { runner.registration_available? }
let(:runner) { build(:ci_runner, *runner_traits, registration_type: registration_type) }
context 'with runner created in UI' do
let(:registration_type) { :authenticated_user }
context 'with runner created within registration deadline' do
let(:runner_traits) { [:online, :created_before_registration_deadline] + extra_runner_traits }
context 'with runner creation not finished' do
let(:extra_runner_traits) { [:unregistered] }
it { is_expected.to be_truthy }
end
context 'with runner creation finished' do
let(:extra_runner_traits) { [] }
it { is_expected.to be_falsy }
end
end
context 'with runner created almost too long ago' do
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it { is_expected.to be_truthy }
end
context 'with runner created too long ago' do
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
it { is_expected.to be_falsy }
end
end
context 'with runner registered from command line' do
let(:registration_type) { :registration_token }
context 'with runner created within registration deadline' do
let(:runner_traits) { [:created_before_registration_deadline] }
it { is_expected.to be_falsy }
end
end
end
describe '#dot_com_gitlab_hosted?' do
subject(:dot_com_gitlab_hosted) { runner.dot_com_gitlab_hosted? }

View File

@ -572,6 +572,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
describe 'ephemeralRegisterUrl' do
let(:runner_args) { { registration_type: :authenticated_user, creator: creator } }
let(:runner_traits) { [] }
let(:query) do
%(
query {
@ -598,24 +599,24 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
context 'with an instance runner' do
let(:creator) { user }
let(:runner) { create(:ci_runner, **runner_args) }
let(:runner) { create(:ci_runner, *runner_traits, **runner_args) }
context 'with valid ephemeral registration' do
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it_behaves_like 'has register url' do
let(:expected_url) { "http://localhost/admin/runners/#{runner.id}/register" }
end
end
context 'when runner ephemeral registration has expired' do
let(:runner) do
create(:ci_runner, created_at: Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago, **runner_args)
end
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
it_behaves_like 'has no register url'
end
context 'when runner has already been registered' do
let(:runner) { create(:ci_runner, :with_runner_manager, **runner_args) }
let(:runner_traits) { [:created_before_registration_deadline] }
it_behaves_like 'has no register url'
end
@ -623,43 +624,47 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
context 'with a group runner' do
let(:creator) { user }
let(:runner) { create(:ci_runner, :group, groups: [group], **runner_args) }
let(:runner) { create(:ci_runner, *runner_traits, :group, groups: [group], **runner_args) }
context 'with valid ephemeral registration' do
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it_behaves_like 'has register url' do
let(:expected_url) { "http://localhost/groups/#{group.path}/-/runners/#{runner.id}/register" }
end
end
context 'when request not from creator' do
let(:creator) { another_admin }
context 'when request not from creator' do
let(:creator) { another_admin }
before do
group.add_owner(another_admin)
before do
group.add_owner(another_admin)
end
it_behaves_like 'has no register url'
end
it_behaves_like 'has no register url'
end
end
context 'with a project runner' do
let(:creator) { user }
let(:runner) { create(:ci_runner, :project, projects: [project1], **runner_args) }
let(:runner) { create(:ci_runner, *runner_traits, :project, projects: [project1], **runner_args) }
context 'with valid ephemeral registration' do
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it_behaves_like 'has register url' do
let(:expected_url) { "http://localhost/#{project1.full_path}/-/runners/#{runner.id}/register" }
end
end
context 'when request not from creator' do
let(:creator) { another_admin }
context 'when request not from creator' do
let(:creator) { another_admin }
before do
project1.add_owner(another_admin)
before do
project1.add_owner(another_admin)
end
it_behaves_like 'has no register url'
end
it_behaves_like 'has no register url'
end
end
end
@ -844,10 +849,11 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
describe 'ephemeralAuthenticationToken' do
subject(:request) { post_graphql(query, current_user: user) }
let_it_be(:creator) { create(:user) }
let_it_be(:creator) { create(:user, owner_of: group) }
let(:created_at) { Time.current }
let(:token_prefix) { registration_type == :authenticated_user ? 'glrt-' : '' }
let(:runner_traits) { [] }
let(:registration_type) {}
let(:query) do
%(
@ -864,18 +870,14 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
create(
:ci_runner,
:group,
*runner_traits,
groups: [group],
creator: creator,
created_at: created_at,
registration_type: registration_type,
token: "#{token_prefix}abc123"
)
end
before_all do
group.add_owner(creator) # Allow creating runners in the group
end
shared_examples 'an ephemeral_authentication_token' do
it 'returns token in ephemeral_authentication_token field' do
request
@ -902,28 +904,30 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
context 'with runner created in UI' do
let(:registration_type) { :authenticated_user }
context 'with runner created in last hour' do
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
context 'with runner created within registration deadline' do
let(:runner_traits) { [:created_before_registration_deadline] + extra_runner_traits }
context 'with runner creation not finished' do
let(:runner_traits) { [:unregistered] }
context 'with no runner manager registered yet' do
it_behaves_like 'an ephemeral_authentication_token'
end
context 'with first runner manager already registered' do
let!(:runner_manager) { create(:ci_runner_machine, runner: runner) }
context 'with runner creation finished' do
let(:runner_traits) { [] }
it_behaves_like 'a protected ephemeral_authentication_token'
end
end
context 'with runner created almost too long ago' do
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it_behaves_like 'an ephemeral_authentication_token'
end
context 'with runner created too long ago' do
let(:created_at) { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago }
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
it_behaves_like 'a protected ephemeral_authentication_token'
end
@ -932,8 +936,8 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
context 'with runner registered from command line' do
let(:registration_type) { :registration_token }
context 'with runner created in last 1 hour' do
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
context 'with runner created within registration deadline' do
let(:runner_traits) { [:created_before_registration_deadline] }
it_behaves_like 'a protected ephemeral_authentication_token'
end
@ -945,6 +949,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
context 'with runner created in UI' do
let(:registration_type) { :authenticated_user }
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
it_behaves_like 'a protected ephemeral_authentication_token'
end

View File

@ -893,6 +893,7 @@ module GraphqlHelpers
allow(selection).to receive(:selection).and_return(selection)
allow(selection).to receive(:selections).and_return(selection)
allow(selection).to receive(:map).and_return(double(include?: true))
allow(selection).to receive_message_chain(:field, :type, :list?).and_return(false)
end
end

View File

@ -465,6 +465,7 @@ RSpec.describe 'Every Sidekiq worker', feature_category: :shared do
'Search::Zoekt::TaskFailedEventWorker' => 1,
'Search::Zoekt::UpdateIndexUsedStorageBytesEventWorker' => 1,
'Search::Zoekt::SaasRolloutEventWorker' => 1,
'Security::ProcessScanEventsWorker' => 3,
'Security::StoreScansWorker' => 3,
'Security::TrackSecureScansWorker' => 1,
'ServiceDeskEmailReceiverWorker' => 3,

View File

@ -1437,10 +1437,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.123.0.tgz#1fa3b1a709755ff7c8ef67e18c0442101655ebf0"
integrity sha512-yjVn+utOTIKk8d9JlvGo6EgJ4TQ+CKpe3RddflAqtsQqQuL/2MlVdtaUePybxYzWIaumFuh5LouQ6BrWyw1niQ==
"@gitlab/ui@108.10.0":
version "108.10.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-108.10.0.tgz#9434054c9e44b395604b2d55416c83c84fdd09ac"
integrity sha512-xfrDcA/UKlhVEGJAhvdiK1qLy7GEABiRWhJmjkoBZRMjr4Q6dpjqVlr8s7hQf9FMdI6tMVbbV3bGBuMPsGNzLA==
"@gitlab/ui@109.0.0":
version "109.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-109.0.0.tgz#349f26cc90b57c066ab8c00017545270fc843b4f"
integrity sha512-WHJeEoigQWbSz0NCwZj9KGRhWZsSGBgabMZmeGkuGsxzIvNP6oIV8roCt8EbeVYbvXmjWA6D6wtxIU2t6RE1iw==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"