Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-04-03 18:07:43 +00:00
parent 35d7589851
commit 45eb4bc75f
113 changed files with 1272 additions and 1301 deletions

View File

@ -1375,7 +1375,6 @@ Gitlab/BoundedContexts:
- 'app/services/authorized_project_update/project_recalculate_per_user_service.rb'
- 'app/services/authorized_project_update/project_recalculate_service.rb'
- 'app/services/auto_merge/base_service.rb'
- 'app/services/auto_merge/merge_when_pipeline_succeeds_service.rb'
- 'app/services/auto_merge_service.rb'
- 'app/services/award_emojis/add_service.rb'
- 'app/services/award_emojis/base_service.rb'

View File

@ -202,7 +202,6 @@ Layout/LineLength:
- 'app/serializers/note_entity.rb'
- 'app/services/application_settings/update_service.rb'
- 'app/services/auth/container_registry_authentication_service.rb'
- 'app/services/auto_merge/merge_when_pipeline_succeeds_service.rb'
- 'app/services/boards/base_items_list_service.rb'
- 'app/services/boards/issues/create_service.rb'
- 'app/services/boards/issues/move_service.rb'

View File

@ -221,7 +221,6 @@ RSpec/AnyInstanceOf:
- 'spec/serializers/merge_request_poll_cached_widget_entity_spec.rb'
- 'spec/serializers/merge_request_poll_widget_entity_spec.rb'
- 'spec/services/application_settings/update_service_spec.rb'
- 'spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb'
- 'spec/services/ci/create_pipeline_service_spec.rb'
- 'spec/services/ci/register_job_service_spec.rb'
- 'spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb'

View File

@ -142,7 +142,6 @@ RSpec/ReturnFromStub:
- 'spec/serializers/merge_request_poll_cached_widget_entity_spec.rb'
- 'spec/services/application_settings/update_service_spec.rb'
- 'spec/services/auto_merge/base_service_spec.rb'
- 'spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb'
- 'spec/services/captcha/captcha_verification_service_spec.rb'
- 'spec/services/ci/create_pipeline_service/logger_spec.rb'
- 'spec/services/ci/pipeline_artifacts/destroy_all_expired_service_spec.rb'

View File

@ -71,7 +71,6 @@ Style/IfUnlessModifier:
- 'app/serializers/merge_request_diff_entity.rb'
- 'app/services/application_settings/update_service.rb'
- 'app/services/authorized_project_update/find_records_due_for_refresh_service.rb'
- 'app/services/auto_merge/merge_when_pipeline_succeeds_service.rb'
- 'app/services/award_emojis/destroy_service.rb'
- 'app/services/badges/update_service.rb'
- 'app/services/boards/issues/list_service.rb'

View File

@ -45,7 +45,11 @@ gem 'bootsnap', '~> 1.18.3', require: false, feature_category: :shared
# Avoid the precompiled native gems because Omnibus needs to build this to ensure
# LD_LIBRARY_PATH is correct: https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/7730
gem 'ffi', '~> 1.17', force_ruby_platform: true, feature_category: :shared
if RUBY_PLATFORM.include?('darwin')
gem 'ffi', '~> 1.17', feature_category: :shared
else
gem 'ffi', '~> 1.17', force_ruby_platform: true, feature_category: :shared
end
gem 'openssl', '~> 3.0', feature_category: :shared

View File

@ -229,6 +229,11 @@ export function parseChronicDuration(string, opts = {}) {
// Given an integer and an optional format,
// returns a formatted string representing elapsed time
export function outputChronicDuration(seconds, opts = {}) {
if (opts.hoursOnly) {
const hours = Math.round(seconds / 3600);
return `${hours}h`;
}
const units = {
years: 0,
months: 0,

View File

@ -2,15 +2,17 @@
import {
GlBadge,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlTooltip,
GlPopover,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import { __ } from '~/locale';
import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
@ -24,12 +26,13 @@ export default {
CiIcon,
GlBadge,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlTooltip,
GlPopover,
GlSprintf,
},
styles: {
actionSizeClasses: ['gl-h-7 gl-w-7'],
flatLeftBorder: ['!gl-rounded-bl-none', '!gl-rounded-tl-none'],
flatRightBorder: ['!gl-rounded-br-none', '!gl-rounded-tr-none'],
},
@ -108,18 +111,33 @@ export default {
expandBtnText() {
return this.expanded ? __('Collapse jobs') : __('Expand jobs');
},
childPipeline() {
return this.isDownstream && this.isSameProject;
isChildPipeline() {
return this.isDownstream && !this.isMultiProject;
},
downstreamTitle() {
return this.childPipeline ? this.sourceJobName : this.pipeline.project.name;
if (this.hasPipelineName) {
return this.pipelineName;
}
if (!this.hasSourceJob) {
return this.projectName;
}
return this.isMultiProject
? `${this.sourceJobName}: ${this.projectName}`
: this.sourceJobName;
},
graphqlPipelineId() {
return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id);
},
hasPipelineName() {
return Boolean(this.pipelineName);
},
hasUpdatePipelinePermissions() {
return Boolean(this.pipeline?.userPermissions?.updatePipeline);
},
hasSourceJob() {
return Boolean(this.pipeline?.sourceJob?.id);
},
isCancelable() {
return Boolean(this.pipeline?.cancelable && this.hasUpdatePipelinePermissions);
},
@ -129,8 +147,8 @@ export default {
isRetryable() {
return Boolean(this.pipeline?.retryable && this.hasUpdatePipelinePermissions);
},
isSameProject() {
return !this.pipeline.multiproject;
isMultiProject() {
return this.pipeline.multiproject;
},
isUpstream() {
return this.type === UPSTREAM;
@ -139,39 +157,59 @@ export default {
if (this.parentPipeline) {
return __('Parent');
}
if (this.childPipeline) {
if (this.isChildPipeline) {
return __('Child');
}
return __('Multi-project');
},
parentPipeline() {
return this.isUpstream && this.isSameProject;
return this.isUpstream && !this.isMultiProject;
},
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
pipelineName() {
return this.pipeline?.name || '';
},
pipelineStatus() {
return this.pipeline.status;
},
projectName() {
return this.pipeline.project.name;
return this.pipeline?.project?.name || '';
},
popoverItems() {
return [
{
condition: this.hasPipelineName,
message: __('%{boldStart}Pipeline:%{boldEnd} %{value}'),
value: this.pipelineName,
},
{
condition: !this.isChildPipeline,
message: __('%{boldStart}Project:%{boldEnd} %{value}'),
value: this.projectName,
},
{
condition: this.hasSourceJob,
message: __('%{boldStart}Created by:%{boldEnd} %{value}'),
value: this.sourceJobName,
},
{
condition: true,
message: __('%{boldStart}Status:%{boldEnd} %{value}'),
value: this.pipeline.status.label,
},
].filter((item) => item.condition);
},
showAction() {
return Boolean(this.action?.method && this.action?.icon && this.action?.ariaLabel);
},
showCardTooltip() {
showCardPopover() {
return !this.hasActionTooltip && !this.isExpandBtnFocus;
},
sourceJobName() {
return this.pipeline.sourceJob?.name ?? '';
},
sourceJobInfo() {
return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
},
cardTooltipText() {
return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
${this.sourceJobInfo}`;
},
},
methods: {
cancelPipeline() {
@ -234,14 +272,26 @@ export default {
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
<gl-tooltip v-if="showCardTooltip" :target="() => $refs.linkedPipeline">
{{ cardTooltipText }}
</gl-tooltip>
<gl-popover
v-if="showCardPopover"
:target="() => $refs.linkedPipeline"
triggers="hover"
placement="bottom"
>
<div v-for="(item, index) in popoverItems" :key="index">
<gl-sprintf :message="item.message">
<template #bold="{ content }">
<strong>{{ content }}</strong>
</template>
<template #value>{{ item.value }}</template>
</gl-sprintf>
</div>
</gl-popover>
<div
class="gl-border gl-w-full gl-rounded-lg gl-border-l-section gl-bg-section gl-p-3"
class="gl-border gl-flex gl-w-full gl-flex-col gl-gap-y-2 gl-rounded-lg gl-border-l-section gl-bg-section gl-p-3"
:class="cardClasses"
>
<div class="gl-flex gl-gap-x-3">
<div class="gl-flex gl-w-26 gl-gap-x-3">
<ci-icon
v-if="!pipelineIsLoading"
:status="pipelineStatus"
@ -249,11 +299,11 @@ export default {
class="gl-self-start"
/>
<div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
<div class="gl-downstream-pipeline-job-width gl-flex gl-flex-col gl-leading-normal">
<div class="gl-flex gl-min-w-0 gl-flex-1 gl-flex-col">
<span class="gl-truncate" data-testid="downstream-title-content">
{{ downstreamTitle }}
</span>
<div class="-gl-m-2 gl-truncate gl-p-2">
<div class="gl-truncate">
<gl-link class="gl-text-sm" :href="pipeline.path" data-testid="pipelineLink"
>#{{ pipeline.id }}</gl-link
>
@ -265,16 +315,21 @@ export default {
:title="action.ariaLabel"
:loading="isActionLoading"
:icon="action.icon"
class="!gl-rounded-full"
:class="$options.styles.actionSizeClasses"
class="gl-h-7 gl-w-7 !gl-rounded-full"
:aria-label="action.ariaLabel"
@click="action.method"
@mouseover="setActionTooltip(true)"
@mouseout="setActionTooltip(false)"
/>
<div v-else :class="$options.styles.actionSizeClasses"></div>
</div>
<div class="gl-ml-7 gl-pt-2">
<span
v-if="hasSourceJob"
class="gl-flex gl-w-26 gl-items-center gl-gap-2 gl-text-sm gl-text-subtle"
>
<gl-icon name="trigger-source" :size="12" class="gl-flex-shrink-0" />
<span class="gl-truncate"> {{ sourceJobName }} </span>
</span>
<div class="gl-cursor-default gl-pt-2">
<gl-badge variant="info" data-testid="downstream-pipeline-label">
{{ label }}
</gl-badge>

View File

@ -16,7 +16,6 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
id
detailsPath
icon
group
text
}
errorMessages {

View File

@ -4,12 +4,15 @@ import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { setUrlFragment, visitUrl } from '~/lib/utils/url_utility';
import { __, n__, sprintf, formatNumber } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
import { reportToSentry } from '~/ci/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import pipelineCiStatusUpdatedSubscription from '~/graphql_shared/subscriptions/pipeline_ci_status_updated.subscription.graphql';
import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
@ -54,6 +57,7 @@ export default {
[DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
[DEFAULT]: __('An unknown error occurred.'),
},
mixins: [glFeatureFlagMixin()],
inject: {
graphqlResourceEtag: {
default: '',
@ -64,6 +68,9 @@ export default {
pipelineIid: {
default: '',
},
pipelineId: {
default: '',
},
},
apollo: {
pipeline: {
@ -93,6 +100,41 @@ export default {
this.isRetrying = false;
}
},
subscribeToMore: {
document() {
return pipelineCiStatusUpdatedSubscription;
},
variables() {
return {
pipelineId: convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipelineId),
};
},
skip() {
return !this.showRealTimePipelineStatus;
},
updateQuery(
previousData,
{
subscriptionData: {
data: { ciPipelineStatusUpdated },
},
},
) {
if (ciPipelineStatusUpdated) {
return {
project: {
...previousData.project,
pipeline: {
...previousData.project.pipeline,
detailedStatus: ciPipelineStatusUpdated.detailedStatus,
},
},
};
}
return previousData;
},
},
},
},
data() {
@ -225,6 +267,9 @@ export default {
isMergeTrainPipeline() {
return this.pipeline.mergeRequestEventType === MERGE_TRAIN_EVENT_TYPE;
},
showRealTimePipelineStatus() {
return this.glFeatures.ciPipelineStatusRealtime;
},
},
methods: {
reportFailure(errorType, errorMessages = []) {
@ -328,7 +373,7 @@ export default {
{{ commitTitle }}
</h3>
<div>
<ci-icon :status="detailedStatus" show-status-text :show-link="false" class="gl-mb-3" />
<ci-icon :status="detailedStatus" show-status-text class="gl-mb-3" />
<div class="gl-mb-3 gl-ml-2 gl-inline-block gl-h-6">
<gl-link
v-if="user"

View File

@ -15,6 +15,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
const {
fullPath,
pipelineIid,
pipelineId,
pipelinesPath,
identityVerificationPath,
identityVerificationRequired,
@ -35,6 +36,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
pipelinesPath,
},
pipelineIid,
pipelineId,
identityVerificationPath,
identityVerificationRequired: parseBoolean(identityVerificationRequired),
mergeTrainsAvailable: parseBoolean(mergeTrainsAvailable),

View File

@ -3,8 +3,9 @@
subscription pipelineCiStatusUpdatedSubscription($pipelineId: CiPipelineID!) {
ciPipelineStatusUpdated(pipelineId: $pipelineId) {
id
retryable
cancelable
detailedStatus {
label
...CiIcon
}
}

View File

@ -19,6 +19,7 @@ import initWorkItemLinks from '~/work_items/components/work_item_links';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import { __ } from '~/locale';
import { NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY } from '~/work_items/constants';
import { issuableInitialDataById, isLegacyIssueType } from './show/utils/issuable_data';
const feedback = {};
@ -31,6 +32,7 @@ if (gon.features?.workItemViewForIssues) {
);
feedback.title = __('New issue look');
feedback.featureName = 'work_item_epic_feedback';
feedback.expiry = NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY;
}
export function initForm() {

View File

@ -115,6 +115,7 @@ export async function mountIssuesListApp() {
wiReportAbusePath,
wiNewCommentTemplatePaths,
hasLinkedItemsEpicsFeature,
timeTrackingLimitToHours,
} = el.dataset;
return new Vue({
@ -202,6 +203,7 @@ export async function mountIssuesListApp() {
hasSubepicsFeature: false,
hasLinkedItemsEpicsFeature: parseBoolean(hasLinkedItemsEpicsFeature),
commentTemplatePaths: JSON.parse(wiNewCommentTemplatePaths),
timeTrackingLimitToHours: parseBoolean(timeTrackingLimitToHours),
},
render: (createComponent) => createComponent(IssuesListApp),
});

View File

@ -34,10 +34,10 @@ export default {
},
computed: {
info() {
return Object.freeze(this.candidate.info);
return this.candidate.info;
},
ciJob() {
return Object.freeze(this.info.ciJob);
return this.info.ciJob;
},
hasMetadata() {
return !isEmpty(this.candidate.metadata);

View File

@ -55,7 +55,6 @@ export const i18n = {
reassign_merge_request: s__('NotificationEvent|Merge request is reassigned'),
reopen_issue: s__('NotificationEvent|Issue is reopened'),
reopen_merge_request: s__('NotificationEvent|Merge request is reopened'),
merge_when_pipeline_succeeds: s__('NotificationEvent|Merge request is set to auto-merge'),
success_pipeline: s__('NotificationEvent|Pipeline is successful'),
approver: s__('NotificationEvent|You are added as an approver on a merge request'),
},

View File

@ -2,6 +2,7 @@ import { addShortcutsExtension } from '~/behaviors/shortcuts';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues/list';
import { initWorkItemsRoot } from '~/work_items';
import { NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY } from '~/work_items/constants';
import { __ } from '~/locale';
mountIssuesListApp();
@ -20,6 +21,7 @@ if (gon.features.workItemViewForIssues) {
);
feedback.title = __('New issue look');
feedback.featureName = 'work_item_epic_feedback';
feedback.expiry = NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY;
}
if (gon.features.workItemsViewPreference || gon.features.workItemViewForIssues) {

View File

@ -2,6 +2,7 @@ import { initForm } from 'ee_else_ce/issues';
import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { initWorkItemsRoot } from '~/work_items';
import { NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY } from '~/work_items/constants';
import { __ } from '~/locale';
initForm();
@ -20,6 +21,7 @@ if (gon.features.workItemViewForIssues) {
);
feedback.title = __('New issue look');
feedback.featureName = 'work_item_epic_feedback';
feedback.expiry = NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY;
}
if (gon.features.workItemsViewPreference || gon.features.workItemViewForIssues) {

View File

@ -4,10 +4,11 @@ import { InternalEvents } from '~/tracking';
import { HISTORY_BUTTON_CLICK } from '~/tracking/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import PipelineCiStatus from '~/vue_shared/components/ci_status/pipeline_ci_status.vue';
import SignatureBadge from '~/commit/components/signature_badge.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import pipelineCiStatusUpdatedSubscription from '~/graphql_shared/subscriptions/pipeline_ci_status_updated.subscription.graphql';
import getRefMixin from '../mixins/get_ref';
import { getRefType } from '../utils/ref_type';
import projectPathQuery from '../queries/project_path.query.graphql';
@ -19,6 +20,7 @@ import CollapsibleCommitInfo from './collapsible_commit_info.vue';
const trackingMixin = InternalEvents.mixin();
export default {
components: {
CiIcon,
CommitInfo,
CollapsibleCommitInfo,
ClipboardButton,
@ -26,7 +28,6 @@ export default {
GlButtonGroup,
GlButton,
GlLoadingIcon,
PipelineCiStatus,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -59,7 +60,40 @@ export default {
error(error) {
throw error;
},
pollInterval: 30000,
subscribeToMore: {
document() {
return pipelineCiStatusUpdatedSubscription;
},
variables() {
return {
pipelineId: this.commit?.pipeline?.id,
};
},
skip() {
return !this.showRealTimePipelineStatus || !this.commit?.pipeline?.id;
},
updateQuery(
previousData,
{
subscriptionData: {
data: { ciPipelineStatusUpdated },
},
},
) {
if (ciPipelineStatusUpdated) {
const updatedData = structuredClone(previousData);
const pipeline =
updatedData.project?.repository?.paginatedTree?.nodes[0]?.lastCommit?.pipelines
?.edges[0]?.node || {};
pipeline.detailedStatus = ciPipelineStatusUpdated.detailedStatus;
return updatedData;
}
return previousData;
},
},
},
},
props: {
@ -126,12 +160,7 @@ export default {
<div class="commit-actions gl-my-2 gl-flex gl-items-start gl-gap-3">
<signature-badge v-if="commit.signature" :signature="commit.signature" class="gl-h-7" />
<div v-if="commit.pipeline.id" class="gl-ml-5 gl-flex gl-h-7 gl-items-center">
<pipeline-ci-status
:pipeline-id="commit.pipeline.id"
:project-full-path="projectPath"
:can-subscribe="showRealTimePipelineStatus"
class="gl-mr-2"
/>
<ci-icon :status="commit.pipeline.detailedStatus" class="gl-mr-2" />
</div>
<gl-button-group class="js-commit-sha-group gl-ml-4 gl-flex gl-items-center">
<gl-button

View File

@ -158,6 +158,7 @@ export default {
this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
this.mediator.addSelfReview();
this.trackEvent('assign_self_as_reviewer_in_mr');
this.saveReviewers();
},
saveReviewers() {

View File

@ -72,7 +72,7 @@ export default {
projectsLoadingError: false,
sortKey: this.customSortKey ?? 'STORAGE_SIZE_DESC',
initialSortBy: this.customSortKey ? null : 'storage',
sortableFields: { storage: !this.customSortKey },
enableSortableFields: !this.customSortKey,
};
},
computed: {
@ -116,10 +116,6 @@ export default {
}
},
onSortChanged({ sortBy, sortDesc }) {
if (!this.sortableFields[sortBy]) {
return;
}
const sortDir = sortDesc ? 'desc' : 'asc';
const sortKey = `${convertToSnakeCase(sortBy)}_size_${sortDir}`.toUpperCase();
this.sortKey = sortKey;
@ -195,7 +191,7 @@ export default {
:is-loading="$apollo.queries.projects.loading"
:help-links="helpLinks"
:sort-by="initialSortBy"
:sortable-fields="sortableFields"
:enable-sortable-fields="enableSortableFields"
@sortChanged="onSortChanged"
/>
<div class="gl-mt-5 gl-flex gl-justify-center">

View File

@ -46,26 +46,27 @@ export default {
required: false,
default: null,
},
sortableFields: {
type: Object,
enableSortableFields: {
type: Boolean,
required: true,
},
},
created() {
this.fields = [
{ key: 'name', label: __('Project') },
{ key: 'storage', label: __('Total'), sortable: this.sortableFields.storage },
{ key: 'repository', label: __('Repository') },
{ key: 'snippets', label: __('Snippets') },
{ key: 'buildArtifacts', label: __('Job artifacts') },
{ key: 'lfsObjects', label: __('LFS') },
{ key: 'packages', label: __('Packages') },
{ key: 'wiki', label: __('Wiki') },
{ key: 'storage', label: __('Total'), sortable: this.enableSortableFields },
{ key: 'repository', label: __('Repository'), sortable: this.enableSortableFields },
{ key: 'snippets', label: __('Snippets'), sortable: this.enableSortableFields },
{ key: 'buildArtifacts', label: __('Job artifacts'), sortable: this.enableSortableFields },
{ key: 'lfsObjects', label: __('LFS'), sortable: this.enableSortableFields },
{ key: 'packages', label: __('Packages'), sortable: this.enableSortableFields },
{ key: 'wiki', label: __('Wiki'), sortable: this.enableSortableFields },
{
key: 'containerRegistry',
label: __('Containers'),
thClass: '!gl-border-l',
tdClass: '!gl-border-l',
sortable: this.enableSortableFields,
},
].map((f) => ({
...f,

View File

@ -12,19 +12,13 @@ export const SUCCESS = 'success';
export const WARNING = 'warning';
export const INFO = 'info';
export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const MWCP_MERGE_STRATEGY = 'merge_when_checks_pass';
export const MTWCP_MERGE_STRATEGY = 'add_to_merge_train_when_checks_pass';
export const MT_MERGE_STRATEGY = 'merge_train';
export const PIPELINE_FAILED_STATE = 'failed';
export const AUTO_MERGE_STRATEGIES = [
MWPS_MERGE_STRATEGY,
MTWCP_MERGE_STRATEGY,
MT_MERGE_STRATEGY,
MWCP_MERGE_STRATEGY,
];
export const AUTO_MERGE_STRATEGIES = [MTWCP_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWCP_MERGE_STRATEGY];
// SP - "Suggest Pipelines"
export const SP_TRACK_LABEL = 'no_pipeline_noticed';

View File

@ -7,7 +7,6 @@ import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import {
MT_MERGE_STRATEGY,
MWCP_MERGE_STRATEGY,
MWPS_MERGE_STRATEGY,
MTWCP_MERGE_STRATEGY,
STATE_MACHINE,
stateToTransitionMap,
@ -362,9 +361,6 @@ export default class MergeRequestStore {
if (availableAutoMergeStrategies.includes(MWCP_MERGE_STRATEGY)) {
return MWCP_MERGE_STRATEGY;
}
if (availableAutoMergeStrategies.includes(MWPS_MERGE_STRATEGY)) {
return MWPS_MERGE_STRATEGY;
}
if (availableAutoMergeStrategies.includes(MTWCP_MERGE_STRATEGY)) {
return MTWCP_MERGE_STRATEGY;
}

View File

@ -1,14 +0,0 @@
#import "~/graphql_shared/fragments/ci_icon.fragment.graphql"
query pipelineCiStatus($fullPath: ID!, $pipelineId: CiPipelineID!) {
project(fullPath: $fullPath) {
id
pipeline(id: $pipelineId) {
id
detailedStatus {
label
...CiIcon
}
}
}
}

View File

@ -1,110 +0,0 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { createAlert } from '~/alert';
import { captureException } from '~/sentry/sentry_browser_wrapper';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import pipelineCiStatus from './graphql/pipeline_ci_status.query.graphql';
import pipelineCiStatusUpdatedSubscription from './graphql/pipeline_ci_status_updated.subscription.graphql';
/*
* Renders a real-time CI status for pipelines.
* This component utilizes a GraphQL subscription to
* get real-time status updates over a WebSocket.
*/
export default {
components: {
CiIcon,
GlLoadingIcon,
},
props: {
pipelineId: {
type: String,
required: true,
},
projectFullPath: {
type: String,
required: true,
},
// Pass a feature flag value from the related view to this prop
// to ensure skipping the subscription is possible based on the flags value.
canSubscribe: {
type: Boolean,
required: true,
},
},
apollo: {
status: {
query: pipelineCiStatus,
variables() {
return {
fullPath: this.projectFullPath,
pipelineId: this.pipelineId,
};
},
update({ project }) {
return project?.pipeline?.detailedStatus;
},
skip() {
return !this.pipelineId || !this.projectFullPath;
},
error(error) {
createAlert({ message: __('An error occurred fetching the pipeline status.') });
captureException(error);
},
subscribeToMore: {
document() {
return pipelineCiStatusUpdatedSubscription;
},
variables() {
return {
pipelineId: this.pipelineId,
};
},
skip() {
return !this.canSubscribe || !this.status;
},
updateQuery(
previousData,
{
subscriptionData: {
data: { ciPipelineStatusUpdated },
},
},
) {
if (previousData && ciPipelineStatusUpdated)
return {
project: {
...previousData.project,
pipeline: {
...previousData.project.pipeline,
detailedStatus: ciPipelineStatusUpdated.detailedStatus,
},
},
};
return previousData;
},
},
},
},
data() {
return {
status: null,
};
},
computed: {
isLoading() {
return this.$apollo.queries.status.loading;
},
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" />
<div v-else>
<ci-icon v-if="status" :status="status" />
</div>
</template>

View File

@ -129,7 +129,7 @@ export default {
<span class="gl-block">{{ getAuthorName(item.author) }}</span>
<time-ago
v-if="item.createdAt"
class="text-1"
class="gl-text-sm"
:time="item.createdAt"
tooltip-placement="bottom"
/>

View File

@ -85,7 +85,7 @@ export default {
</script>
<template>
<div class="gl-flex gl-w-full gl-justify-between">
<div class="gl-flex gl-w-full gl-justify-between gl-gap-3">
<div class="flex-xl-nowrap gl-flex gl-w-19/20 gl-flex-wrap gl-items-center gl-gap-3">
<gl-icon
v-gl-tooltip

View File

@ -118,9 +118,9 @@ export default {
};
</script>
<template>
<div class="gl-flex gl-w-full gl-items-start gl-justify-between">
<div class="gl-flex gl-w-full gl-items-start gl-justify-between gl-gap-3">
<div
class="flex-xl-nowrap gl-flex gl-min-w-0 gl-grow gl-flex-wrap gl-items-center gl-justify-between gl-gap-2 gl-pr-2"
class="gl-flex gl-min-w-0 gl-grow gl-flex-wrap gl-items-center gl-justify-between gl-gap-3 xl:gl-flex-nowrap"
>
<div class="item-title gl-flex gl-min-w-0 gl-items-center gl-gap-3">
<gl-icon
@ -141,7 +141,7 @@ export default {
{{ itemContent.title }}
</gl-link>
</div>
<div class="item-info-area gl-flex gl-shrink-0 gl-grow gl-gap-3">
<div class="item-info-area gl-mt-1 gl-flex gl-shrink-0 gl-grow gl-gap-3">
<div class="item-meta gl-flex gl-grow gl-flex-wrap-reverse gl-gap-3 sm:gl-justify-between">
<div class="item-path-area item-path-id gl-flex gl-flex-wrap gl-items-center gl-gap-3">
<span class="gl-font-sm gl-text-subtle"> !{{ itemContent.iid }} </span>

View File

@ -127,15 +127,11 @@ export default {
<div>
<ul
ref="list-body"
class="gl-m-0 gl-list-none gl-p-0"
class="content-list"
data-testid="work-item-dev-items-list"
:data-list-length="list.length"
>
<li
v-for="item in list"
:key="itemId(item)"
class="gl-border-b gl-py-4 first:!gl-pt-0 last:gl-border-none last:!gl-pb-0"
>
<li v-for="item in list" :key="itemId(item)">
<component
:is="itemComponent(item)"
:item-content="item"

View File

@ -18,8 +18,6 @@ import CreateTimelogForm from '~/sidebar/components/time_tracking/create_timelog
import SetTimeEstimateForm from '~/sidebar/components/time_tracking/set_time_estimate_form.vue';
import TimeTrackingReport from '~/sidebar/components/time_tracking/time_tracking_report.vue';
const options = { hoursPerDay: 8, daysPerMonth: 20, format: 'short' };
export default {
i18n: {
addTimeTrackingMessage: s__(
@ -39,6 +37,7 @@ export default {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
inject: ['timeTrackingLimitToHours'],
props: {
canUpdate: {
type: Boolean,
@ -75,14 +74,14 @@ export default {
},
computed: {
humanTimeEstimate() {
return outputChronicDuration(this.timeEstimate, options);
return outputChronicDuration(this.timeEstimate, this.options);
},
humanTotalTimeSpent() {
return outputChronicDuration(this.totalTimeSpent, options) ?? '0h';
return outputChronicDuration(this.totalTimeSpent, this.options) ?? '0h';
},
progressBarTooltipText() {
const timeDifference = this.totalTimeSpent - this.timeEstimate;
const time = outputChronicDuration(Math.abs(timeDifference), options) ?? '0h';
const time = outputChronicDuration(Math.abs(timeDifference), this.options) ?? '0h';
return isPositiveInteger(timeDifference)
? sprintf(s__('TimeTracking|%{time} over'), { time })
: sprintf(s__('TimeTracking|%{time} remaining'), { time });
@ -108,6 +107,17 @@ export default {
timeTrackingModalId() {
return `time-tracking-modal-${this.workItemId}`;
},
limitToHours() {
return this.timeTrackingLimitToHours || false;
},
options() {
return {
hoursPerDay: 8,
daysPerMonth: 20,
format: 'short',
hoursOnly: this.limitToHours,
};
},
},
};
</script>
@ -221,7 +231,11 @@ export default {
size="lg"
:title="__('Time tracking report')"
>
<time-tracking-report :timelogs="timelogs" :work-item-iid="workItemIid" />
<time-tracking-report
:limit-to-hours="limitToHours"
:timelogs="timelogs"
:work-item-iid="workItemIid"
/>
</gl-modal>
</div>
</template>

View File

@ -266,6 +266,7 @@ export const NEW_WORK_ITEM_IID = 'new-work-item-iid';
export const NEW_WORK_ITEM_GID = 'gid://gitlab/WorkItem/new';
export const NEW_EPIC_FEEDBACK_PROMPT_EXPIRY = '2024-12-31';
export const NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY = '2025-04-25';
export const FEATURE_NAME = 'work_item_epic_feedback';
export const DETAIL_VIEW_QUERY_PARAM_NAME = 'show';

View File

@ -9,6 +9,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import { __ } from '~/locale';
import { NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY } from '~/work_items/constants';
import App from './components/app.vue';
import WorkItemBreadcrumb from './components/work_item_breadcrumb.vue';
import activeDiscussionQuery from './components/design_management/graphql/client/active_design_discussion.query.graphql';
@ -59,6 +60,7 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType, withTabs } = {}
canCreateProjects,
newProjectPath,
hasIssueDateFilterFeature,
timeTrackingLimitToHours,
} = el.dataset;
const isGroup = workspaceType === WORKSPACE_GROUP;
@ -97,6 +99,7 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType, withTabs } = {}
);
feedback.title = __('New issue look');
feedback.featureName = 'work_item_epic_feedback';
feedback.expiry = NEW_ISSUE_FEEDBACK_PROMPT_EXPIRY;
}
if (
@ -151,6 +154,7 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType, withTabs } = {}
newIssuePath: '',
newProjectPath,
hasIssueDateFilterFeature: parseBoolean(hasIssueDateFilterFeature),
timeTrackingLimitToHours: parseBoolean(timeTrackingLimitToHours),
},
mounted() {
performanceMarkAndMeasure({

View File

@ -1,9 +1 @@
@import 'page_bundles/mixins_and_variables_and_functions';
// CSS util classes
/**
These are deprecated in favor of the Tailwind CSS utilities.
Please check https://gitlab-org.gitlab.io/frontend/tailwind-documentation
to see the available utility classes.
**/
@import 'utilities';

View File

@ -1,18 +0,0 @@
/**
Please note: These are deprecated in favor of the Tailwind CSS utilities.
Check https://gitlab-org.gitlab.io/frontend/tailwind-documentation
to see the available utility classes. If you cannot find the class you need,
see https://docs.gitlab.com/ee/development/fe_guide/style/scss.html#where-should-you-put-new-utility-classes.
**/
$type-scale: (
1: 12px,
2: 14px,
4: 20px,
);
@each $index, $size in $type-scale {
#{'.text-#{$index}'} {
font-size: $size;
}
}

View File

@ -15,6 +15,10 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:ci_improved_project_pipeline_analytics, project)
end
before_action only: [:show] do
push_frontend_feature_flag(:ci_pipeline_status_realtime, project)
end
before_action only: [:new, :create, :manual_variables] do
push_frontend_feature_flag(:ci_inputs_for_pipelines, project)
end

View File

@ -52,7 +52,6 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
__typename
id
iid
complete
usesNeeds
userPermissions {
updatePipeline
@ -62,6 +61,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
nodes {
# eslint-disable-next-line @graphql-eslint/require-id-when-available -- for detailedStatus.id
...LinkedPipelineData
name
}
}
upstream {
@ -108,7 +108,6 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
id
name
kind
scheduledAt
needs {
__typename
nodes {

View File

@ -58,6 +58,13 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!, $refType:
node {
__typename
id
detailedStatus {
__typename
id
detailsPath
icon
text
}
}
}
}

View File

@ -18,7 +18,7 @@ module Types
description: 'Name of the input.'
field :value,
Inputs::ValueInputType,
Inputs::ValueType,
null: true,
description: 'Value of the input.'
end

View File

@ -1,5 +1,9 @@
# frozen_string_literal: true
# This class represents a GraphQL `Input` object that allows users to set CI inputs values when running a pipeline or
# configuring a pipeline schedule. The `Inputs` namespace refers to CI inputs, and the `InputType` class name refers to
# a GraphQL `Input` object.
module Types
module Ci
module Inputs
@ -13,7 +17,7 @@ module Types
description: 'Name of the input.'
argument :value,
Inputs::ValueInputType,
Inputs::ValueType,
required: true,
description: 'Value of the input.'

View File

@ -29,11 +29,11 @@ module Types
description: 'Indicates whether the input is required.',
method: :required?
field :default, Types::Ci::Inputs::ValueInputType,
field :default, Types::Ci::Inputs::ValueType,
null: true,
description: 'Default value for the input, if provided.'
field :options, Types::Ci::Inputs::ValueInputType,
field :options, Types::Ci::Inputs::ValueType,
null: true,
description: 'Possible values that the input can take, if provided.'

View File

@ -1,10 +1,13 @@
# frozen_string_literal: true
# This class represents the value of a CI input. It is used to provide default values to UI forms that users can
# use to populate inputs for a pipeline, and to configure inputs values for pipeline schedules.
module Types
module Ci
module Inputs
class ValueInputType < BaseScalar
graphql_name 'CiInputsValueInputType'
class ValueType < BaseScalar
graphql_name 'CiInputsValue'
description 'Value for a CI input. Can be a string, array, number, or boolean.'
def self.coerce_input(value, _ctx)

View File

@ -181,7 +181,8 @@ module IssuesHelper
quick_actions_help_path: help_page_path('user/project/quick_actions.md'),
releases_path: project_releases_path(project, format: :json),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
show_new_issue_link: show_new_issue_link?(project).to_s
show_new_issue_link: show_new_issue_link?(project).to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s
)
end
@ -193,7 +194,8 @@ module IssuesHelper
group_id: group.id,
has_any_issues: @has_issues.to_s,
has_any_projects: @has_projects.to_s,
new_project_path: new_project_path(namespace_id: group.id)
new_project_path: new_project_path(namespace_id: group.id),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s
)
end

View File

@ -37,6 +37,7 @@ module Projects
full_path: project.full_path,
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
pipeline_iid: pipeline.iid,
pipeline_id: pipeline.id,
pipelines_path: project_pipelines_path(project)
}
end

View File

@ -26,7 +26,8 @@ module WorkItemsHelper
can_create_projects: can?(current_user, :create_projects, group).to_s,
new_project_path: new_project_path(namespace_id: group&.id),
group_id: group&.id,
has_issue_date_filter_feature: has_issue_date_filter_feature?(resource_parent, current_user).to_s
has_issue_date_filter_feature: has_issue_date_filter_feature?(resource_parent, current_user).to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s
}
end

View File

@ -1,49 +0,0 @@
# frozen_string_literal: true
module AutoMerge
class MergeWhenPipelineSucceedsService < AutoMerge::BaseService
def execute(merge_request)
super do
add_system_note(merge_request)
end
end
def process(merge_request)
logger.info("Processing Automerge - MWPS")
return unless merge_request.diff_head_pipeline_success?
logger.info("Pipeline Success - MWPS")
return unless merge_request.mergeable?
logger.info("Merge request mergeable - MWPS")
merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params)
end
def cancel(merge_request)
super do
SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, project, current_user)
end
end
def abort(merge_request, reason)
super do
SystemNoteService.abort_merge_when_pipeline_succeeds(merge_request, project, current_user, reason)
end
end
def available_for?(_merge_request)
false
end
private
def add_system_note(merge_request)
SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.diff_head_pipeline.sha) if merge_request.saved_change_to_auto_merge_enabled?
end
def notify(merge_request)
notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled?
end
end
end

View File

@ -3,11 +3,10 @@
class AutoMergeService < BaseService
include Gitlab::Utils::StrongMemoize
STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'
STRATEGY_MERGE_WHEN_CHECKS_PASS = 'merge_when_checks_pass'
# Only used in EE
STRATEGY_ADD_TO_MERGE_TRAIN_WHEN_CHECKS_PASS = 'add_to_merge_train_when_checks_pass'
STRATEGIES = [STRATEGY_MERGE_WHEN_CHECKS_PASS, STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze
STRATEGIES = [STRATEGY_MERGE_WHEN_CHECKS_PASS].freeze
class << self
def all_strategies_ordered_by_preference
@ -22,7 +21,6 @@ class AutoMergeService < BaseService
def strategy_to_class_map
{
STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS => AutoMerge::MergeWhenPipelineSucceedsService,
STRATEGY_MERGE_WHEN_CHECKS_PASS => AutoMerge::MergeWhenChecksPassService
}
end

View File

@ -205,8 +205,7 @@ module MergeRequests
return unless @project.ff_merge_must_be_possible?
merge_requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request|
unless merge_request.auto_merge_strategy == AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS ||
merge_request.auto_merge_strategy == AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS
unless merge_request.auto_merge_strategy == AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS
next
end

View File

@ -175,21 +175,6 @@ module SystemNoteService
::SystemNotes::MergeRequestsService.new(noteable: noteable, container: project, author: author).abort_auto_merge(reason)
end
# Called when 'merge when pipeline succeeds' is executed
def merge_when_pipeline_succeeds(noteable, project, author, sha)
::SystemNotes::MergeRequestsService.new(noteable: noteable, container: project, author: author).merge_when_pipeline_succeeds(sha)
end
# Called when 'merge when pipeline succeeds' is canceled
def cancel_merge_when_pipeline_succeeds(noteable, project, author)
::SystemNotes::MergeRequestsService.new(noteable: noteable, container: project, author: author).cancel_merge_when_pipeline_succeeds
end
# Called when 'merge when pipeline succeeds' is aborted
def abort_merge_when_pipeline_succeeds(noteable, project, author, reason)
::SystemNotes::MergeRequestsService.new(noteable: noteable, container: project, author: author).abort_merge_when_pipeline_succeeds(reason)
end
def handle_merge_request_draft(noteable, project, author)
::SystemNotes::MergeRequestsService.new(noteable: noteable, container: project, author: author).handle_merge_request_draft
end

View File

@ -26,30 +26,6 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
# Called when 'merge when pipeline succeeds' is executed
def merge_when_pipeline_succeeds(sha)
body = "enabled an automatic merge when the pipeline for #{sha} succeeds"
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
# Called when 'merge when pipeline succeeds' is canceled
def cancel_merge_when_pipeline_succeeds
body = 'canceled the automatic merge'
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
# Called when 'merge when pipeline succeeds' is aborted
def abort_merge_when_pipeline_succeeds(reason)
body = "aborted the automatic merge because #{format_reason(reason)}"
##
# TODO: Abort message should be sent by the system, not a particular user.
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187.
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
def handle_merge_request_draft
action = noteable.draft? ? "draft" : "ready"

View File

@ -18,13 +18,13 @@
%ul
%li
%span= safe_format(s_('BulkImport|%{count} placeholder users matched to users.'), count: @success_count)
%span= safe_format(ns_('BulkImport|%{count} placeholder user matched to user.', 'BulkImport|%{count} placeholder users matched to users.', @success_count), count: @success_count)
- if @has_errors
%li{ style: error_style }
%strong= safe_format(s_('BulkImport|%{count} placeholder users not matched to users.'), count: @failed_count)
%strong= safe_format(ns_('BulkImport|%{count} placeholder user not matched to user.', 'BulkImport|%{count} placeholder users not matched to users.', @failed_count), count: @failed_count)
- if @skipped_count > 0
%li{ style: emphasis_style }
= safe_format(s_('BulkImport|%{count} placeholder users skipped.'), count: @skipped_count)
= safe_format(ns_('BulkImport|%{count} placeholder user skipped.', 'BulkImport|%{count} placeholder users skipped.', @skipped_count), count: @skipped_count)
- if @has_errors
%p{ style: text_style }

View File

@ -6,12 +6,12 @@
<%= safe_format(s_('BulkImport|All items assigned to placeholder users were reassigned to users in %{strong_open}%{group}%{strong_close} according to the uploaded CSV file.'), strong_open: '', strong_close: '', group: @group.name) %>
<% end %>
- <%= safe_format(s_('BulkImport|%{count} placeholder users matched to users.'), count: @success_count) %>
- <%= safe_format(ns_('BulkImport|%{count} placeholder user matched to user.', 'BulkImport|%{count} placeholder users matched to users.', @success_count), count: @success_count) %>
<% if @has_errors %>
- <%= safe_format(s_('BulkImport|%{count} placeholder users not matched to users.'), count: @failed_count) %>
- <%= safe_format(ns_('BulkImport|%{count} placeholder user not matched to user.', 'BulkImport|%{count} placeholder users not matched to users.', @failed_count), count: @failed_count) %>
<% end %>
<% if @skipped_count > 0 %>
- <%= safe_format(s_('BulkImport|%{count} placeholder users skipped.'), count: @skipped_count) %>
- <%= safe_format(ns_('BulkImport|%{count} placeholder user skipped.', 'BulkImport|%{count} placeholder users skipped.', @skipped_count), count: @skipped_count) %>
<% end %>
<% if @has_errors %>

View File

@ -0,0 +1,17 @@
---
description: Number of times the "assign self" button is pressed in a Merge Request
internal_events: true
action: assign_self_as_reviewer_in_mr
identifiers:
- project
- namespace
- user
product_group: code_review
product_categories:
- code_review_workflow
milestone: '17.11'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/183774
tiers:
- free
- premium
- ultimate

View File

@ -1,8 +0,0 @@
---
name: add_timezone_to_web_operations
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64084
rollout_issue_url: https://gitlab.com/gitlab-org/gitaly/-/issues/3655
milestone: '14.1'
type: development
group: group::gitaly
default_enabled: false

View File

@ -0,0 +1,23 @@
---
key_path: counts.count_total_assign_self_as_reviewer_in_mr
description: Count of times that the 'assign self' button in the reviewers section is pressed in an MR
product_group: code_review
product_categories:
- code_review_workflow
performance_indicator_type: []
value_type: number
status: active
milestone: '17.11'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/183774
time_frame:
- 28d
- 7d
- all
data_source: internal_events
data_category: optional
tiers:
- free
- premium
- ultimate
events:
- name: assign_self_as_reviewer_in_mr

View File

@ -1,16 +1,14 @@
# frozen_string_literal: true
common = 'app/assets/stylesheets/framework/common.scss'
utilities = 'app/assets/stylesheets/utilities.scss'
def get_css_files(files, common_filepath, utilities_filepath)
def get_css_files(files, common_filepath)
files.select do |file|
file.include?(common_filepath) ||
file.include?(utilities_filepath)
file.include?(common_filepath)
end
end
changed_util_files = get_css_files(helper.all_changed_files.to_a, common, utilities)
changed_util_files = get_css_files(helper.all_changed_files.to_a, common)
unless changed_util_files.empty?
@ -27,25 +25,10 @@ unless changed_util_files.empty?
If it is a component class shared across items, could it be added to the component as a utility class or to the component's stylesheet? If not, you can ignore this warning.
If it is a new utility class, is there another class that shares the same values in either this file or in `#{utilities}`? If not, please add it to `#{utilities}`, following the [Gitlab UI naming style](https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss).
MARKDOWN
end
if changed_util_files.include?(utilities)
markdown(<<~MARKDOWN)
### Addition to `#{utilities}`
You have added a new rule to `#{utilities}`. Are you sure you need this rule?
If it is a component class shared across items, could it be added to the component as a utility class or to the component's stylesheet? If not, consider adding it to `#{common}`
If it is a new utility class, is there another class that shares the same values in either this file or in `#{utilities}`? If not, please be sure this addition follows the [Gitlab UI naming style](https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss) so it may be removed when these rules are included. See [Include gitlab-ui utility-class library](https://gitlab.com/gitlab-org/gitlab/issues/36857) for more about this project.
MARKDOWN
end
warn "This merge request adds a new rule to #{common} or #{utilities}."
warn "This merge request adds a new rule to #{common}."
end

View File

@ -260,6 +260,10 @@ you need the following information:
[Microsoft Quickstart Register an Application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) documentation
to obtain the tenant ID, client ID, and client secret for your app.
When you register a Microsoft Azure application, you must grant API permissions to allow GitLab to
retrieve required details. You must provide at least the `openid`, `profile`, and `email` permissions.
For more information, see the [Microsoft documentation for configuring app permissions for a web API](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-access-web-apis#add-permissions-to-access-microsoft-graph).
{{< alert type="note" >}}
All accounts provisioned by Azure must have an email address defined. If an email address is not defined, Azure assigns a randomly generated address. If you've configured [domain sign-up restrictions](../settings/sign_up_restrictions.md#allow-or-deny-sign-ups-using-specific-email-domains), this random address might prevent the account from being created.

View File

@ -119,6 +119,7 @@ attribute. As a prerequisite, you must use an LDAP server that:
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/328074) in GitLab 16.9.
- [Added](https://gitlab.com/gitlab-org/gitlab/-/issues/514025) `reverse_issuer_and_subject` and `reverse_issuer_and_serial_number` formats in GitLab 17.11.
{{< /history >}}
@ -134,12 +135,17 @@ Use the following attributes to customize the field GitLab checks and the format
| -------------------------- | ------------------------------------------------------------ |
| `principal_name` | `X509:<PN>alice@example.com` |
| `rfc822_name` | `X509:<RFC822>bob@example.com` |
| `issuer_and_subject` | `X509:<I>DC=com,DC=example,CN=EXAMPLE-DC-CA<S>DC=com,DC=example,OU=UserAccounts,CN=cynthia` |
| `subject` | `X509:<S>DC=com,DC=example,OU=UserAccounts,CN=dennis` |
| `issuer_and_serial_number` | `X509:<I>DC=com,DC=example,CN=CONTOSO-DC-CA<SR>1181914561` |
| `issuer_and_subject` | `X509:<I>DC=com,DC=example,CN=EXAMPLE-DC-CA<S>DC=com,DC=example,OU=UserAccounts,CN=cynthia` |
| `reverse_issuer_and_serial_number` | `X509:<I>CN=CONTOSO-DC-CA,DC=example,DC=com<SR>1181914561` |
| `reverse_issuer_and_subject` | `X509:<I>CN=EXAMPLE-DC-CA,DC=example,DC=com<S>DC=com,DC=example,OU=UserAccounts,CN=cynthia` |
For `issuer_and_serial_number`, the `<SR>` portion is in reverse-byte-order, with the least-significant byte first. For more information, see [how to map a user to a certificate using the altSecurityIdentities attribute](https://learn.microsoft.com/en-us/archive/blogs/spatdsg/howto-map-a-user-to-a-certificate-via-all-the-methods-available-in-the-altsecurityidentities-attribute).
The reverse issuer formats sort the issuer string from the smallest unit to the largest. Some
Active Directory servers store certificates in this format.
{{< alert type="note" >}}
If no `smartcard_ad_cert_format` is specified, but an LDAP server is configured with `active_directory: true` and smart cards enabled, GitLab defaults to the behavior of 16.8 and earlier, and uses `certificateExactMatch` on the `userCertificate` attribute.

View File

@ -32,6 +32,8 @@ The package registry supports the following formats:
| [Generic packages](../../user/packages/generic_packages/_index.md) | 13.5+ |
| [Helm Charts](../../user/packages/helm_repository/_index.md) | 14.1+ |
The package registry is also used to store [model registry data](../../user/project/ml/model_registry/_index.md).
## Accepting contributions
The below table lists formats that are not supported, but are accepting Community contributions for. Consider contributing to GitLab. This [development documentation](../../development/packages/_index.md)

View File

@ -22369,7 +22369,7 @@ CI input saved for a pipeline schedule.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="ciinputsfieldname"></a>`name` | [`String!`](#string) | Name of the input. |
| <a id="ciinputsfieldvalue"></a>`value` | [`CiInputsValueInputType`](#ciinputsvalueinputtype) | Value of the input. |
| <a id="ciinputsfieldvalue"></a>`value` | [`CiInputsValue`](#ciinputsvalue) | Value of the input. |
### `CiInputsSpec`
@ -22379,10 +22379,10 @@ Input for pipeline creation.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="ciinputsspecdefault"></a>`default` | [`CiInputsValueInputType`](#ciinputsvalueinputtype) | Default value for the input, if provided. |
| <a id="ciinputsspecdefault"></a>`default` | [`CiInputsValue`](#ciinputsvalue) | Default value for the input, if provided. |
| <a id="ciinputsspecdescription"></a>`description` | [`String`](#string) | Description of the input. |
| <a id="ciinputsspecname"></a>`name` | [`String!`](#string) | Name of the input. |
| <a id="ciinputsspecoptions"></a>`options` | [`CiInputsValueInputType`](#ciinputsvalueinputtype) | Possible values that the input can take, if provided. |
| <a id="ciinputsspecoptions"></a>`options` | [`CiInputsValue`](#ciinputsvalue) | Possible values that the input can take, if provided. |
| <a id="ciinputsspecregex"></a>`regex` | [`String`](#string) | Regular expression pattern that the input value must match if provided. |
| <a id="ciinputsspecrequired"></a>`required` | [`Boolean!`](#boolean) | Indicates whether the input is required. |
| <a id="ciinputsspectype"></a>`type` | [`CiInputsType!`](#ciinputstype) | Input data type. |
@ -43568,7 +43568,6 @@ Representation of whether a GitLab merge request can be merged.
| <a id="mergestrategyenumadd_to_merge_train_when_checks_pass"></a>`ADD_TO_MERGE_TRAIN_WHEN_CHECKS_PASS` | Use the add_to_merge_train_when_checks_pass merge strategy. |
| <a id="mergestrategyenummerge_train"></a>`MERGE_TRAIN` | Use the merge_train merge strategy. |
| <a id="mergestrategyenummerge_when_checks_pass"></a>`MERGE_WHEN_CHECKS_PASS` | Use the merge_when_checks_pass merge strategy. |
| <a id="mergestrategyenummerge_when_pipeline_succeeds"></a>`MERGE_WHEN_PIPELINE_SUCCEEDS` | Use the merge_when_pipeline_succeeds merge strategy. |
### `MergeTrainStatus`
@ -45485,7 +45484,7 @@ A `CiCatalogResourcesVersionID` is a global ID. It is encoded as a string.
An example `CiCatalogResourcesVersionID` is: `"gid://gitlab/Ci::Catalog::Resources::Version/1"`.
### `CiInputsValueInputType`
### `CiInputsValue`
Value for a CI input. Can be a string, array, number, or boolean.
@ -47903,7 +47902,7 @@ Attributes for defining an input.
| ---- | ---- | ----------- |
| <a id="ciinputsinputdestroy"></a>`destroy` | [`Boolean`](#boolean) | Set to `true` to delete the input. |
| <a id="ciinputsinputname"></a>`name` | [`String!`](#string) | Name of the input. |
| <a id="ciinputsinputvalue"></a>`value` | [`CiInputsValueInputType!`](#ciinputsvalueinputtype) | Value of the input. |
| <a id="ciinputsinputvalue"></a>`value` | [`CiInputsValue!`](#ciinputsvalue) | Value of the input. |
### `CiVariableInput`

View File

@ -224,7 +224,7 @@ Supported attributes:
| `last_used_before` | datetime (ISO 8601) | No | If defined, returns tokens last used before the specified time. |
| `revoked` | boolean | No | If `true`, only returns revoked tokens. |
| `search` | string | No | If defined, returns tokens that include the specified value in the name. |
| `sort` | string | No | If defined, sorts the results by the specified value. Possible values: `created_asc`, `created_desc`, `expires_before_asc`, `expires_after_desc`, `last_used_before_asc`, `last_used_after_desc`, `name_asc`, `name_desc`. |
| `sort` | string | No | If defined, sorts the results by the specified value. Possible values: `created_asc`, `created_desc`, `expires_asc`, `expires_desc`, `last_used_asc`, `last_used_desc`, `name_asc`, `name_desc`. |
| `state` | string | No | If defined, returns tokens with the specified state. Possible values: `active` and `inactive`. |
Example request:
@ -259,7 +259,7 @@ Example response:
Example of unsuccessful responses:
- `401: Unauthorized`
- `404 Personal access token(s) Not Found`
- `404 Group Not Found`
## Create a personal access token for a service account user

View File

@ -21,8 +21,7 @@ Utility classes are generated by [Tailwind CSS](https://tailwindcss.com/). There
### What CSS utility classes are deprecated?
Classes in [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/utilities.scss)
and [`common.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/framework/common.scss)
Classes in [`common.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/framework/common.scss)
are being deprecated. Classes in [`common.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/framework/common.scss)
that use non-design-system values should be avoided. Use classes with conforming values instead.

View File

@ -0,0 +1,265 @@
---
stage: Plan
group: Project Management
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: 'Tutorial: Set up a group with multiple projects for issue triage'
---
{{< details >}}
- Tier: Free, Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
<!-- vale gitlab_base.FutureTense = NO -->
Issue triage is the process of categorization according to type and severity.
As your project grows and people create more issues, it's worth creating a workflow for how you'll
triage incoming issues.
In this tutorial, you'll learn how to set up a GitLab group with multiple projects for this scenario.
To set up GitLab for issue triage in a project:
1. [Create a group](#create-a-group)
1. [Create projects inside a group](#create-projects-inside-a-group)
1. [Decide on the criteria for types, severity, and priority](#decide-on-the-criteria-for-types-severity-and-priority)
1. [Document your criteria](#document-your-criteria)
1. [Create scoped labels](#create-scoped-labels)
1. [Prioritize the new labels](#prioritize-the-new-labels)
1. [Create a group issue triage board](#create-a-group-issue-triage-board)
1. [Create issues for features](#create-issues-for-features)
## Before you begin
- If you're using an existing project for this tutorial, make sure you have at least the Reporter role
for the project.
- If your existing project does not have a parent group, create a group and [promote the project labels to group labels](../../user/project/labels.md#promote-a-project-label-to-a-group-label).
## Create a group
A [group](../../user/group/_index.md) is, in essence, a container for multiple projects. It allows users to manage multiple projects and communicate with group members all at once.
To create a new group:
1. On the left sidebar, at the top, select **Create new** ({{< icon name="plus" >}}) and select **New group**.
1. Select **Create group**.
1. Enter the group details.
- For **Group name**, enter `triage-tutorial`.
1. Select **Create group** at the bottom of the page.
## Create projects inside a group
To manage issue-tracking across multiple projects, you need to create at least two projects inside your group.
To create a new project:
1. On the left sidebar, at the top, select **Create new** ({{< icon name="plus" >}}) and **New project/repository**.
1. Select **Create blank project**.
1. Enter the project details:
- For **Project name**, enter `test-project-1`. For more information, see project [naming rules](../../user/reserved_names.md#rules-for-usernames-project-and-group-names-and-slugs).
1. Select **Create project** at the bottom of the page.
1. Repeat this process to create a second project named `test-project-2`.
## Decide on the criteria for types, severity, and priority
Next, you'll need to determine:
- **Types** of issues you want to recognize. If you need a more granular approach, you
can also create subtypes for each type. Types help categorize work to get an understanding of the
kind of work that is requested of your team.
- Levels of **priorities** and **severities** to define the impact that incoming work has on end
users and to assist in prioritization.
For this tutorial, suppose you've decided on the following:
- Type: `Bug`, `Feature`, and `Maintenance`
- Priority: `1`, `2`, `3`, and `4`
- Severity: `1`, `2`, `3`, and `4`
For inspiration, see how we define these at GitLab:
- [Types and subtypes](https://handbook.gitlab.com/handbook/engineering/metrics/#work-type-classification)
- [Priority](https://handbook.gitlab.com/handbook/engineering/infrastructure/engineering-productivity/issue-triage/#priority)
- [Severity](https://handbook.gitlab.com/handbook/engineering/infrastructure/engineering-productivity/issue-triage/#severity)
## Document your criteria
After you agree on all the criteria, write it all down somewhere your team mates can always access.
For example, add it to a [wiki](../../user/project/wiki/_index.md) in your project, or your company
handbook published with [GitLab Pages](../../user/project/pages/_index.md).
<!-- Idea for expanding this tutorial:
Add steps for [creating a wiki page](../../user/project/wiki/_index.md#create-a-new-wiki-page). -->
## Create scoped labels
{{< details >}}
- Tier: Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
Next, you'll create labels to add to issues to categorize them.
The best tool for this is [scoped labels](../../user/project/labels.md#scoped-labels), which you
can use to set mutually exclusive attributes.
Checking with the list of types, severities, and priorities you've assembled
[previously](#decide-on-the-criteria-for-types-severity-and-priority), you'll want to create matching
scoped labels.
The double colon (`::`) in the name of a scoped label prevents two labels of the same scope being
used together.
For example, if you add the `type::feature` label to an issue that already has `type::bug`, the
previous one is removed.
{{< alert type="note" >}}
Scoped labels are available in the Premium and Ultimate tier.
If you're on the Free tier, you can use regular labels instead.
However, they aren't mutually exclusive.
{{< /alert >}}
To create each label:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Manage > Labels**.
1. Select **New label**.
1. In the **Title** field, enter the name of the label. Start with `type::bug`.
1. Optional. Select a color from the available colors, or enter a hex color value for
a specific color in the **Background color** field.
1. Select **Create label**.
Repeat steps 3-6 to create all the labels you need.
Here are some examples:
- `type::bug`
- `type::feature`
- `type::maintenance`
- `priority::1`
- `priority::2`
- `priority::3`
- `priority::4`
- `severity::1`
- `severity::2`
- `severity::3`
- `severity::4`
## Prioritize the new labels
Now, set the new labels as priority labels.
Doing this ensures that the most important issues show on top
of the issue list if you sort by priority or label priority.
To learn what happens when you sort by priority or label priority, see
[Sorting and ordering issue lists](../../user/project/issues/sorting_issue_lists.md).
To prioritize labels:
1. On the Labels page, next to a label you want to prioritize, select **Prioritize** ({{< icon name="star-o" >}}).
This label now appears at the top of the label list, under **Prioritized labels**.
1. To change the relative priority of these labels, drag them up and down the list.
Labels higher in the list get higher priority.
1. Prioritize all the labels you previously created.
Make sure that labels of higher priority and severity are higher on the list than those of lower values.
![List of priority labels](img/priority_labels_v16_3.png)
## Create a group issue triage board
To prepare for the incoming issue backlog, create an [issue board](../../user/project/issue_board.md) that organizes issues by label.
You'll use it to quickly create issues and add labels to them by dragging cards to various lists.
To set up your issue board:
1. Decide on the scope of the board.
For example, create a group issue board](../../user/project/issue_board.md#group-issue-boards that you'll use to assign
severity to issues.
1. On the left sidebar, select **Search or go to** and find your **triage-tutorial** group.
1. Select **Plan > Issue boards**.
1. In the upper-left corner of the issue board page, select the dropdown list with the current board name.
1. Select **Create new board**.
1. In the **Title** field, enter `Issue triage (by severity)`.
1. Keep the **Show the Open list** checkbox selected and clear the **Show the Closed list** one.
1. Select **Create board**. You should see an empty board.
1. Create a list for the `severity::1` label:
1. In the upper-right corner of the issue board page, select **Create list**.
1. In the column that appears, from the **Value** dropdown list, select the `severity::1` label.
1. At the bottom of the list, select **Add to board**.
1. Repeat the previous step for labels `severity::2`, `severity::3`, and `severity::4`.
For now, the lists in your board should be empty. Next, you'll populate them with some issues.
## Create issues for features
To track upcoming features and bugs, you must create some issues.
Issues belong in projects, but you can also create them directly from your group issue board.
Start by creating some issues for planned features.
You can create issues for bugs as you find them (hopefully not too many!).
To create an issue from your **Issue triage (by severity)** board:
1. Go to the **Open** list.
This list shows issues that don't fit any other board list.
If you already know which severity label your issue should have, you can create it directly from
that label's list.
Keep in mind that each issue created from a label list is given that label.
For now, we'll proceed with using the **Open** list.
1. On the **Open** list, select the **Create new issue** icon ({{< icon name="plus" >}}).
1. Complete the fields:
- Under **Title**, enter `User registration`.
- Select the project to which this issue applies. We will select `test-project-1`.
1. Select **Create issue**.
1. Repeat these steps to create a few more issues.
For example, if you're building an app, imagine that `test-project-1` and `test-project-2` refer to
the application's backend and frontend respectively.
Create the following issues, assigning to projects as you see fit:
- `User registration`
- `Profile creation`
- `Search functionality`
- `Add to favorites`
- `Push notifications`
- `Social sharing`
- `In-app messaging`
- `Track progress`
- `Feedback and ratings`
- `Settings and preferences`
Your first triage issue board is ready!
Try it out by dragging some issues from the **Open** list to one of the label lists to add one of
the severity labels.
![Triage issue board with example issues](img/triage_board_v16_3.png)
## Next steps
Next, you can:
- Tweak how you use issue boards. Some options include:
- Edit your current issue board to also have lists for priority and type labels.
This way, you'll make the board wider and might require some horizontal scrolling.
- Create separate issue boards named `Issue triage (by priority)` and `Issue triage (by type)`.
This way, you'll keep various types of triage work separate, but will require switching between
boards.
- [Set up issue boards for team hand-off](../boards_for_teams/_index.md).
- Browse issues by priority or severity in issue lists,
[filtered by each label](../../user/project/issues/managing_issues.md#filter-the-list-of-issues).
If it's available to you, make use of
[the "is one of" filter operator](../../user/project/issues/managing_issues.md#filter-with-the-or-operator).
- Break the issues down into [tasks](../../user/tasks.md).
- Create policies that help automate issue triage in a project with the [`gitlab-triage` gem](https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage).
Generate summary reports with heatmaps like the following:
![Example triage report heatmap](img/triage_report_v16_3.png)
To learn more about issue triage at GitLab, see [Issue Triage](https://handbook.gitlab.com/handbook/engineering/infrastructure/engineering-productivity/issue-triage/)
and [Triage Operations](https://handbook.gitlab.com/handbook/engineering/infrastructure/engineering-productivity/triage-operations/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -28,8 +28,6 @@ Custom roles allow an organization to create user roles with the precise privile
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of the custom roles feature, see [[Demo] Ultimate Guest can view code on private repositories via custom role](https://www.youtube.com/watch?v=46cp_-Rtxps).
You can discuss individual custom role and permission requests in [issue 391760](https://gitlab.com/gitlab-org/gitlab/-/issues/391760).
{{< alert type="note" >}}
Most custom roles are considered [billable users that use a seat](#billing-and-seat-usage). When you add a user to your group with a custom role and you are about to incur additional charges for having more seats than are included in your subscription, a warning is displayed.
@ -362,10 +360,9 @@ For information on available permissions, see [custom permissions](abilities.md)
## Contribute new permissions
If a permission doesn't exist, you can:
If a permission does not exist, you can:
- Discuss individual custom role and permission requests in [issue 391760](https://gitlab.com/gitlab-org/gitlab/-/issues/391760).
- Create an issue to request the permission with the [permission proposal issue template](https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Permission%2520Proposal).
- Contribute to GitLab and [add the permission](../../development/permissions/custom_roles.md).

View File

@ -86,6 +86,102 @@ If through group sync the user was assigned:
- A higher role, they are a direct member of the group.
- A role that is the same as or lower, they are an inherited member of the group.
## Automatic member removal
After a group sync, users who are not members of a mapped SAML group are removed from the group.
On GitLab.com, users in the top-level group are assigned the
default membership role instead of being removed.
For example, in the following diagram:
- Alex Garcia signs into GitLab and is removed from GitLab Group C because they don't belong
to SAML Group C.
- Sidney Jones belongs to SAML Group C, but is not added to GitLab Group C because they have
not yet signed in.
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: How group membership of users is determined before sign in if group sync is set up.
subgraph SAML users
SAMLUserA[Sidney Jones]
SAMLUserB[Zhang Wei]
SAMLUserC[Alex Garcia]
SAMLUserD[Charlie Smith]
end
subgraph SAML groups
SAMLGroupA["Group A"] --> SAMLGroupB["Group B"]
SAMLGroupA --> SAMLGroupC["Group C"]
SAMLGroupA --> SAMLGroupD["Group D"]
end
SAMLGroupB --> |Member|SAMLUserA
SAMLGroupB --> |Member|SAMLUserB
SAMLGroupC --> |Member|SAMLUserA
SAMLGroupC --> |Member|SAMLUserB
SAMLGroupD --> |Member|SAMLUserD
SAMLGroupD --> |Member|SAMLUserC
```
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: User membership for Sidney when she has not signed into group C, and group B has not configured group links.
subgraph GitLab users
GitLabUserA[Sidney Jones]
GitLabUserB[Zhang Wei]
GitLabUserC[Alex Garcia]
GitLabUserD[Charlie Smith]
end
subgraph GitLab groups
GitLabGroupA["Group A<br> (SAML configured)"] --> GitLabGroupB["Group B<br> (SAML Group Link not configured)"]
GitLabGroupA --> GitLabGroupC["Group C<br> (SAML Group Link configured)"]
GitLabGroupA --> GitLabGroupD["Group D<br> (SAML Group Link configured)"]
end
GitLabGroupB --> |Member|GitLabUserA
GitLabGroupC --> |Member|GitLabUserB
GitLabGroupC --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserD
```
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: How membership of Alex Garcia works once she has signed into a group that has group links enabled.
subgraph GitLab users
GitLabUserA[Sidney Jones]
GitLabUserB[Zhang Wei]
GitLabUserC[Alex Garcia]
GitLabUserD[Charlie Smith]
end
subgraph GitLab groups after Alex Garcia signs in
GitLabGroupA[Group A]
GitLabGroupA["Group A<br> (SAML configured)"] --> GitLabGroupB["Group B<br> (SAML Group Link not configured)"]
GitLabGroupA --> GitLabGroupC["Group C<br> (SAML Group Link configured)"]
GitLabGroupA --> GitLabGroupD["Group D<br> (SAML Group Link configured)"]
end
GitLabGroupB --> |Member|GitLabUserA
GitLabGroupC --> |Member|GitLabUserB
GitLabGroupD --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserD
```
## Configure SAML Group Sync
Adding or changing a group sync configuration may remove users from the mapped GitLab group,
@ -232,34 +328,34 @@ Microsoft has [announced](https://azure.microsoft.com/en-us/updates/azure-ad-is-
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of group sync using Microsoft Azure, see [Demo: SAML Group Sync](https://youtu.be/Iqvo2tJfXjg).
Azure AD sends up to 150 groups in the groups claim. When users are members of more than 150 groups Azure AD sends a
group overage claim attribute in the SAML response. Then group memberships must be obtained using the Microsoft Graph API.
Azure AD sends up to 150 groups in the `groups` claim.
When using Azure AD with SAML Group Sync, if a user in your organization is a member of more than 150 groups,
Azure AD sends a `groups` claim attribute in the SAML response for [group overages](https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles#group-overages),
and the user may be automatically removed from groups.
The [Graph API endpoint](https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof?view=graph-rest-1.0&tabs=http#http-request) supports only a
[user object ID](https://learn.microsoft.com/en-us/partner-center/find-ids-and-domain-names#find-the-user-object-id) or
[userPrincipalName](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/plan-connect-userprincipalname#what-is-userprincipalname)
as the [configured](_index.md#azure) Unique User Identifier (Name identifier) attribute.
To avoid this issue, you can use the Azure AD integration, which:
When the integration processes Group Sync, only Group Links configured with
group unique identifiers (like `12345678-9abc-def0-1234-56789abcde`) are supported.
- Is not limited to 150 groups.
- Uses the Microsoft Graph API to obtain all user memberships.
The [Graph API endpoint](https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof?view=graph-rest-1.0&tabs=http#http-request) accepts only a
[user object ID](https://learn.microsoft.com/en-us/partner-center/find-ids-and-domain-names#find-the-user-object-id) or
[userPrincipalName](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/plan-connect-userprincipalname#what-is-userprincipalname)
as the [Azure-configured](_index.md#azure) Unique User Identifier (Name identifier) attribute.
- Supports only Group Links configured with group unique identifiers (like `12345678-9abc-def0-1234-56789abcde`)
when it processes Group Sync.
To integrate Microsoft Azure AD, you must:
Alternatively, you can change the [group claims](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-fed-group-claims#configure-the-microsoft-entra-application-registration-for-group-attributes) to use the **Groups assigned to the application** option.
1. Configure Azure AD to enable GitLab to communicate with the Microsoft Graph API.
1. Configure GitLab.
The following table lists the GitLab settings and the corresponding Azure AD fields for the configuration:
| GitLab setting | Azure field |
| -------------- | ------------------------------------------ |
| Tenant ID | Directory (tenant) ID |
| Client ID | Application (client) ID |
| Client Secret | Value (on **Certificates & secrets** page) |
![Manage Group Claims](img/Azure-manage-group-claims_v15_9.png)
### Configure Azure AD
As part of the integration, you must allow GitLab to communicate with the Microsoft Graph API.
<!-- vale gitlab_base.SentenceSpacing = NO -->
To configure Azure AD:
1. In the [Azure Portal](https://portal.azure.com), go to **Microsoft Entra ID > App registrations > All applications**, and select your GitLab SAML application.
1. Under **Essentials**, the **Application (client) ID** and **Directory (tenant) ID** values are displayed. Copy these values, because you need them for the GitLab configuration.
1. In the left navigation, select **Certificates & secrets**.
@ -278,10 +374,20 @@ The following table lists the GitLab settings and the corresponding Azure AD fie
### Configure GitLab
With this configuration, if a user signs in with SAML and Azure sends a group overage claim in the response,
After you configure Azure AD, you must configure GitLab to communicate with Azure AD.
With this configuration, if a user signs in with SAML and Azure sends a `group` claim in the response,
GitLab initiates a Group Sync job to call the Microsoft Graph API and retrieve the user's group membership.
Then the GitLab group membership is updated according to SAML Group Links.
The following table lists the GitLab settings and the corresponding Azure AD fields for the configuration:
| GitLab setting | Azure field |
| -------------- | ------------------------------------------ |
| Tenant ID | Directory (tenant) ID |
| Client ID | Application (client) ID |
| Client Secret | Value (on **Certificates & secrets** page) |
{{< tabs >}}
{{< tab title="GitLab.com" >}}
@ -358,113 +464,3 @@ To enable global group memberships lock:
1. Select **Settings > General**.
1. Expand the **Visibility and access controls** section.
1. Ensure that **Lock memberships to SAML Group Links synchronization** is selected.
## Automatic member removal
After a group sync, users who are not members of a mapped SAML group are removed from the group.
On GitLab.com, users in the top-level group are assigned the
default membership role instead of being removed.
For example, in the following diagram:
- Alex Garcia signs into GitLab and is removed from GitLab Group C because they don't belong
to SAML Group C.
- Sidney Jones belongs to SAML Group C, but is not added to GitLab Group C because they have
not yet signed in.
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: How group membership of users is determined before sign in if group sync is set up.
subgraph SAML users
SAMLUserA[Sidney Jones]
SAMLUserB[Zhang Wei]
SAMLUserC[Alex Garcia]
SAMLUserD[Charlie Smith]
end
subgraph SAML groups
SAMLGroupA["Group A"] --> SAMLGroupB["Group B"]
SAMLGroupA --> SAMLGroupC["Group C"]
SAMLGroupA --> SAMLGroupD["Group D"]
end
SAMLGroupB --> |Member|SAMLUserA
SAMLGroupB --> |Member|SAMLUserB
SAMLGroupC --> |Member|SAMLUserA
SAMLGroupC --> |Member|SAMLUserB
SAMLGroupD --> |Member|SAMLUserD
SAMLGroupD --> |Member|SAMLUserC
```
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: User membership for Sidney when she has not signed into group C, and group B has not configured group links.
subgraph GitLab users
GitLabUserA[Sidney Jones]
GitLabUserB[Zhang Wei]
GitLabUserC[Alex Garcia]
GitLabUserD[Charlie Smith]
end
subgraph GitLab groups
GitLabGroupA["Group A<br> (SAML configured)"] --> GitLabGroupB["Group B<br> (SAML Group Link not configured)"]
GitLabGroupA --> GitLabGroupC["Group C<br> (SAML Group Link configured)"]
GitLabGroupA --> GitLabGroupD["Group D<br> (SAML Group Link configured)"]
end
GitLabGroupB --> |Member|GitLabUserA
GitLabGroupC --> |Member|GitLabUserB
GitLabGroupC --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserD
```
```mermaid
%%{init: { "fontFamily": "GitLab Sans" }}%%
graph TB
accTitle: Automatic member removal
accDescr: How membership of Alex Garcia works once she has signed into a group that has group links enabled.
subgraph GitLab users
GitLabUserA[Sidney Jones]
GitLabUserB[Zhang Wei]
GitLabUserC[Alex Garcia]
GitLabUserD[Charlie Smith]
end
subgraph GitLab groups after Alex Garcia signs in
GitLabGroupA[Group A]
GitLabGroupA["Group A<br> (SAML configured)"] --> GitLabGroupB["Group B<br> (SAML Group Link not configured)"]
GitLabGroupA --> GitLabGroupC["Group C<br> (SAML Group Link configured)"]
GitLabGroupA --> GitLabGroupD["Group D<br> (SAML Group Link configured)"]
end
GitLabGroupB --> |Member|GitLabUserA
GitLabGroupC --> |Member|GitLabUserB
GitLabGroupD --> |Member|GitLabUserC
GitLabGroupD --> |Member|GitLabUserD
```
### User that belongs to many SAML groups automatically removed from GitLab group
When using Azure AD with SAML, if any user in your organization is a member of more than 150 groups and you use SAML Group Sync,
that user may lose their group memberships.
For more information, see
[Microsoft Group overages](https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles#group-overages).
The Microsoft Azure AD integration uses the Microsoft Graph API to obtain all user memberships and is
not limited to 150 groups.
Otherwise, you can work around this issue by changing the [group claims](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-fed-group-claims#configure-the-microsoft-entra-application-registration-for-group-attributes) to use the `Groups assigned to the application` option instead.
![Manage Group Claims](img/Azure-manage-group-claims_v15_9.png)

View File

@ -455,7 +455,7 @@ If all users are receiving a `404` after signing in to the identity provider (Id
- In the GitLab configuration by [matching it to the HTTPS endpoint of GitLab](../../../integration/saml.md#configure-saml-support-in-gitlab).
- As the `Assertion Consumer Service URL` or equivalent when setting up the SAML app on your IdP.
- Verify if the `404` is related to [the user having too many groups assigned to them in their Azure IdP](group_sync.md#user-that-belongs-to-many-saml-groups-automatically-removed-from-gitlab-group).
- Verify if the `404` is related to [the user having too many groups assigned to them in their Azure IdP](group_sync.md#microsoft-azure-active-directory-integration).
If a subset of users are receiving a `404` after signing in to the IdP, first verify audit events if the user gets added to the group and then immediately removed. Alternatively, if the user can successfully sign in, but they do not show as [a member of the top-level group](../_index.md#search-a-group):

View File

@ -190,6 +190,7 @@ Users are notified of the following events:
| Group access expired | Group members | Sent when user's access to a group expires in seven days. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12704) in GitLab 16.3._ |
| Project access expired | Project members | Sent when user's access to a project expires in seven days. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12704) in GitLab 16.3._ |
| Group scheduled for deletion | Group Owners | Sent when group is scheduled for deletion. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/522883) in GitLab 17.11_ |
| Project scheduled for deletion | Project Owners | Sent when project is scheduled for deletion. _[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/522883) in GitLab 17.11_ |
## Notifications on issues, merge requests, and epics

View File

@ -115,9 +115,11 @@ The **Add status check** form is then shown.
Filling in the form and selecting the **Add status check** button creates a new status check.
The status check is applied to all new merge requests, but does not apply retroactively to existing merge requests.
### Update a status check service
Within the **Status checks** sub-section, select **Edit** ({{< icon name="pencil" >}})
In the **Status checks** sub-section, select **Edit** ({{< icon name="pencil" >}})
next to the status check you want to edit.
The **Update status check** form is then shown.
@ -129,7 +131,9 @@ You cannot see or modify the value of the HMAC shared secret. To change the shar
{{< /alert >}}
Changing the values in the form and selecting the **Update status check** button updates the status check.
To update the status check, change the values in the form and select **Update status check**.
Status check updates are applied to all new merge requests, but do not apply retroactively to existing merge requests.
### Form values

View File

@ -36,6 +36,10 @@ For more information about model registry features and capabilities, see [epic 9
## Access the model registry
The model registry is controlled by the package registry setting.
Before using the model registry, make
sure the [package registry is enabled](../../../../administration/packages/_index.md#enable-or-disable-the-package-registry).
To access the model registry, on the left sidebar, select **Deploy > Model registry**.
If **Model registry** is not available, ensure that it has been enabled.

View File

@ -25,11 +25,7 @@ module Gitlab
@email = email
@gl_id = gl_id
@timezone = if Feature.enabled?(:add_timezone_to_web_operations)
convert_timezone(timezone)
else
system_timezone
end
@timezone = convert_timezone(timezone)
end
def ==(other)

View File

@ -640,6 +640,9 @@ msgstr ""
msgid "%{board_target} not found"
msgstr ""
msgid "%{boldStart}Created by:%{boldEnd} %{value}"
msgstr ""
msgid "%{boldStart}Merge blocked:%{boldEnd} %{count} check failed"
msgid_plural "%{boldStart}Merge blocked:%{boldEnd} %{count} checks failed"
msgstr[0] ""
@ -648,6 +651,12 @@ msgstr[1] ""
msgid "%{boldStart}Merge with caution%{boldEnd}: Override added"
msgstr ""
msgid "%{boldStart}Pipeline:%{boldEnd} %{value}"
msgstr ""
msgid "%{boldStart}Project:%{boldEnd} %{value}"
msgstr ""
msgid "%{boldStart}Ready to be merged with caution%{boldEnd}: Override added"
msgstr ""
@ -657,6 +666,9 @@ msgstr ""
msgid "%{boldStart}Ready to merge!%{boldEnd}"
msgstr ""
msgid "%{boldStart}Status:%{boldEnd} %{value}"
msgstr ""
msgid "%{bold_start}%{count}%{bold_end} issue"
msgid_plural "%{bold_start}%{count}%{bold_end} issues"
msgstr[0] ""
@ -6652,9 +6664,6 @@ msgstr ""
msgid "An error occurred fetching the dropdown data."
msgstr ""
msgid "An error occurred fetching the pipeline status."
msgstr ""
msgid "An error occurred fetching the project authors."
msgstr ""
@ -11037,14 +11046,20 @@ msgstr ""
msgid "BulkImport| %{host} is running outdated GitLab version (v%{version})"
msgstr ""
msgid "BulkImport|%{count} placeholder users matched to users."
msgstr ""
msgid "BulkImport|%{count} placeholder user matched to user."
msgid_plural "BulkImport|%{count} placeholder users matched to users."
msgstr[0] ""
msgstr[1] ""
msgid "BulkImport|%{count} placeholder users not matched to users."
msgstr ""
msgid "BulkImport|%{count} placeholder user not matched to user."
msgid_plural "BulkImport|%{count} placeholder users not matched to users."
msgstr[0] ""
msgstr[1] ""
msgid "BulkImport|%{count} placeholder users skipped."
msgstr ""
msgid "BulkImport|%{count} placeholder user skipped."
msgid_plural "BulkImport|%{count} placeholder users skipped."
msgstr[0] ""
msgstr[1] ""
msgid "BulkImport|%{feature} (require v%{version})"
msgstr ""
@ -17935,9 +17950,6 @@ msgstr ""
msgid "Created branch '%{branch_name}' and a merge request to resolve this issue."
msgstr ""
msgid "Created by %{job}"
msgstr ""
msgid "Created by me"
msgstr ""
@ -30496,9 +30508,15 @@ msgstr ""
msgid "If this was a mistake, you can %{link_start}retain the group%{link_end} before %{deletion_date}."
msgstr ""
msgid "If this was a mistake, you can %{link_start}retain the project%{link_end} before %{deletion_date}."
msgstr ""
msgid "If this was a mistake, you can retain the group before %{deletion_date}: %{retention_url}"
msgstr ""
msgid "If this was a mistake, you can retain the project before %{deletion_date}: %{retention_url}"
msgstr ""
msgid "If using GitHub, youll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
msgstr ""
@ -39829,9 +39847,6 @@ msgstr ""
msgid "NotificationEvent|Merge request is reopened"
msgstr ""
msgid "NotificationEvent|Merge request is set to auto-merge"
msgstr ""
msgid "NotificationEvent|Merge request receives a push"
msgstr ""
@ -65322,6 +65337,9 @@ msgstr ""
msgid "View your group: %{group_url}"
msgstr ""
msgid "View your project: %{project_url}"
msgstr ""
msgid "Viewed"
msgstr ""
@ -69396,6 +69414,9 @@ msgstr ""
msgid "Your profile"
msgstr ""
msgid "Your project %{project_name} has been marked for deletion and will be removed in %{days}."
msgstr ""
msgid "Your projects"
msgstr ""

View File

@ -65,7 +65,7 @@
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language-rust": "0.5.2",
"@gitlab/svgs": "3.126.0",
"@gitlab/ui": "111.10.2",
"@gitlab/ui": "112.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-20250401183248",
@ -146,7 +146,7 @@
"deckar01-task_list": "^2.3.1",
"dexie": "^3.2.3",
"diff": "^3.4.0",
"dompurify": "^3.2.4",
"dompurify": "^3.2.5",
"dropzone": "^4.2.0",
"editorconfig": "^0.15.3",
"emoji-regex": "^10.3.0",
@ -305,7 +305,7 @@
"swagger-cli": "^4.0.4",
"tailwindcss": "^3.4.1",
"timezone-mock": "^1.0.8",
"vite": "^6.2.3",
"vite": "^6.2.4",
"vite-plugin-ruby": "^5.1.1",
"vue-loader-vue3": "npm:vue-loader@17.4.2",
"vue-test-utils-compat": "0.0.14",

View File

@ -24,7 +24,10 @@ module QA
expect(page).to have_content(jira_issue_description)
Page::Project::Issue::Show.perform do |issue|
work_item_enabled = Page::Project::Issue::Show.perform(&:work_item_enabled?)
page_type = work_item_enabled ? Page::Project::WorkItem::Show : Page::Project::Issue::Show
page_type.perform do |issue|
expect(issue).to have_label(jira_issue_label_1)
expect(issue).to have_label(jira_issue_label_2)
end

View File

@ -116,7 +116,6 @@ function resolveCompilationTargets(filter) {
ignore: [
SCSS_PARTIAL_GLOB,
'**/bootstrap_migration*', // TODO: Prefix file name with _ (and/or move to framework)
'**/utilities.scss', // TODO: Prefix file name with _
],
},
],

View File

@ -141,7 +141,6 @@ spec/frontend/members/components/table/members_pagination_spec.js
spec/frontend/members/components/table/members_table_spec.js
spec/frontend/milestones/components/milestone_combobox_spec.js
spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
spec/frontend/ml/model_registry/components/candidate_detail_spec.js
spec/frontend/ml/model_registry/components/model_edit_spec.js
spec/frontend/ml/model_registry/components/model_version_create_spec.js
spec/frontend/notes/components/discussion_notes_spec.js

View File

@ -133,15 +133,6 @@ FactoryBot.define do
target_branch { "master" }
end
trait :merge_when_pipeline_succeeds do
auto_merge_enabled { true }
auto_merge_strategy { AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS }
merge_user { author }
merge_params do
{ 'auto_merge_strategy' => AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, sha: diff_head_sha }
end
end
trait :merge_when_checks_pass do
auto_merge_enabled { true }
auto_merge_strategy { AutoMergeService::STRATEGY_MERGE_WHEN_CHECKS_PASS }

View File

@ -111,18 +111,27 @@ RSpec.describe 'Commits', feature_category: :source_code_management do
end
end
describe 'Cancel all builds' do
it 'cancels commit', :js, :sidekiq_might_not_need_inline do
visit pipeline_path(pipeline)
click_on 'Cancel pipeline'
expect(page).to have_content 'Canceled'
describe 'Cancel jobs' do
let!(:pipeline) do
create(
:ci_pipeline,
project: project,
user: creator,
ref: project.default_branch,
sha: project.commit.sha,
status: :running,
created_at: 5.months.ago
)
end
end
describe 'Cancel build' do
it 'cancels build', :js, :sidekiq_might_not_need_inline do
before do
visit pipeline_path(pipeline)
find_by_testid('cancel-pipeline').click
wait_for_requests
click_on 'Cancel pipeline'
wait_for_requests
end
it 'cancels pipeline and jobs', :js, :sidekiq_might_not_need_inline do
expect(page).to have_content 'Canceled'
end
end

View File

@ -325,6 +325,12 @@ describe('outputChronicDuration', () => {
).toBe('6 months 1 day 1 hour');
});
it('returns hours only if hoursOnly option specified, rounded to nearest hour', () => {
expect(outputChronicDuration(60 * 60 * 24 * 3 + 60 * 60 * 3, { hoursOnly: true })).toBe('75h');
expect(outputChronicDuration(4 * 3600 + 60 + 1, { hoursOnly: true })).toBe('4h');
expect(outputChronicDuration(5 * 3600, { hoursOnly: true })).toBe('5h');
});
describe('when the format is not specified', () => {
it('uses the default format', () => {
expect(outputChronicDuration(2 * 3600 + 20 * 60)).toBe('2 hrs 20 mins');

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { GlButton, GlLoadingIcon, GlPopover } from '@gitlab/ui';
import { createWrapper } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@ -36,7 +36,7 @@ describe('Linked pipeline', () => {
const findButton = () => wrapper.findComponent(GlButton);
const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline');
const findCardTooltip = () => wrapper.findComponent(GlTooltip);
const findCardPopover = () => wrapper.findComponent(GlPopover);
const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title-content');
const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button');
const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' });
@ -104,15 +104,6 @@ describe('Linked pipeline', () => {
expect(wrapper.text()).toContain(`#${props.pipeline.id}`);
});
it('adds the card tooltip text to the DOM', () => {
expect(findCardTooltip().exists()).toBe(true);
expect(findCardTooltip().text()).toContain(mockPipeline.project.name);
expect(findCardTooltip().text()).toContain(mockPipeline.status.label);
expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name);
expect(findCardTooltip().text()).toContain(mockPipeline.id.toString());
});
it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => {
expect(findPipelineLabel().text()).toBe('Multi-project');
});
@ -165,6 +156,35 @@ describe('Linked pipeline', () => {
});
});
describe('card title/popover', () => {
it.each`
name | pipelineProps | expectedTitle | shouldContain | shouldNotContain
${'with pipeline name'} | ${{ name: 'Pipeline name' }} | ${'Pipeline name'} | ${['Pipeline name', mockPipeline.status.label, mockPipeline.sourceJob.name]} | ${[]}
${'multi-project'} | ${{ multiproject: true }} | ${`${mockPipeline.sourceJob.name}: ${mockPipeline.project.name}`} | ${[mockPipeline.project.name, mockPipeline.sourceJob.name, mockPipeline.status.label]} | ${[]}
${'in-project child'} | ${{ multiproject: false }} | ${mockPipeline.sourceJob.name} | ${[mockPipeline.sourceJob.name, mockPipeline.status.label]} | ${[mockPipeline.project.name]}
`(
'$name: renders correct card title and popover content',
({ pipelineProps, expectedTitle, shouldContain, shouldNotContain }) => {
const pipelineConfig = {
...downstreamProps,
pipeline: { ...mockPipeline, ...pipelineProps },
};
createComponent({ propsData: pipelineConfig });
expect(findDownstreamPipelineTitle().text()).toBe(expectedTitle);
const popoverText = findCardPopover().text();
shouldContain.forEach((content) => {
expect(popoverText).toContain(content);
});
shouldNotContain.forEach((content) => {
expect(popoverText).not.toContain(content);
});
},
);
});
describe('action button', () => {
describe('with permissions', () => {
describe('on an upstream', () => {
@ -206,11 +226,11 @@ describe('Linked pipeline', () => {
${findRetryButton} | ${'retry button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
expect(findCardPopover().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
expect(findCardPopover().exists()).toBe(false);
});
describe('and the retry button is clicked', () => {
@ -273,11 +293,11 @@ describe('Linked pipeline', () => {
${findCancelButton} | ${'cancel button'}
${findExpandButton} | ${'expand button'}
`('hides the card tooltip when $name is hovered', async ({ findElement }) => {
expect(findCardTooltip().exists()).toBe(true);
expect(findCardPopover().exists()).toBe(true);
await findElement().trigger('mouseover');
expect(findCardTooltip().exists()).toBe(false);
expect(findCardPopover().exists()).toBe(false);
});
describe('and the cancel button is clicked', () => {

View File

@ -16,6 +16,7 @@ export default {
},
sourceJob: {
__typename: 'CiJob',
id: '1',
name: 'test_c',
},
project: {

View File

@ -1,6 +1,7 @@
import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { createMockSubscription } from 'mock-apollo-client';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@ -13,6 +14,8 @@ import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry
import HeaderActions from '~/ci/pipeline_details/header/components/header_actions.vue';
import HeaderBadges from '~/ci/pipeline_details/header/components/header_badges.vue';
import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql';
import pipelineCiStatusUpdatedSubscription from '~/graphql_shared/subscriptions/pipeline_ci_status_updated.subscription.graphql';
import {
pipelineHeaderSuccess,
pipelineHeaderRunning,
@ -24,12 +27,15 @@ import {
pipelineRetryMutationResponseFailed,
pipelineCancelMutationResponseFailed,
pipelineDeleteMutationResponseFailed,
mockPipelineStatusUpdatedResponse,
} from '../mock_data';
Vue.use(VueApollo);
describe('Pipeline header', () => {
let wrapper;
let mockedSubscription;
let apolloProvider;
const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess);
const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning);
@ -84,10 +90,14 @@ describe('Pipeline header', () => {
identityVerificationRequired: false,
identityVerificationPath: '#',
pipelineIid: 1,
pipelineId: 100,
paths: {
pipelinesPath: '/namespace/my-project/-/pipelines',
fullProject: '/namespace/my-project',
},
glFeatures: {
ciPipelineStatusRealtime: true,
},
};
const createMockApolloProvider = (handlers) => {
@ -95,10 +105,18 @@ describe('Pipeline header', () => {
};
const createComponent = (handlers = defaultHandlers) => {
mockedSubscription = createMockSubscription();
apolloProvider = createMockApolloProvider(handlers);
apolloProvider.defaultClient.setRequestHandler(
pipelineCiStatusUpdatedSubscription,
() => mockedSubscription,
);
wrapper = shallowMountExtended(PipelineHeader, {
provide: defaultProvideOptions,
stubs: { GlSprintf },
apolloProvider: createMockApolloProvider(handlers),
apolloProvider,
});
return waitForPromises();
@ -403,5 +421,36 @@ describe('Pipeline header', () => {
expect(findHeaderActions().props('isDeleting')).toBe(false);
});
});
describe('subscription', () => {
it('updates pipeline status when subscription updates', async () => {
await createComponent([
[getPipelineDetailsQuery, runningHandler],
[cancelPipelineMutation, cancelMutationHandlerFailed],
]);
const {
data: {
project: {
pipeline: { detailedStatus },
},
},
} = pipelineHeaderRunning;
expect(findStatus().props('status')).toStrictEqual(detailedStatus);
mockedSubscription.next(mockPipelineStatusUpdatedResponse);
await waitForPromises();
expect(findStatus().props('status')).toStrictEqual({
__typename: 'DetailedStatus',
detailsPath: '/root/simple-ci-project/-/pipelines/1257',
icon: 'status_success',
id: 'success-1255-1255',
text: 'Passed',
});
});
});
});
});

View File

@ -1205,3 +1205,22 @@ export const failedRetryMutationResponse = {
},
},
};
export const mockPipelineStatusUpdatedResponse = {
data: {
ciPipelineStatusUpdated: {
id: 'gid://gitlab/Ci::Pipeline/1257',
retryable: false,
cancelable: false,
__typename: 'Pipeline',
detailedStatus: {
detailsPath: '/root/simple-ci-project/-/pipelines/1257',
icon: 'status_success',
id: 'success-1255-1255',
label: 'passed',
text: 'Passed',
__typename: 'DetailedStatus',
},
},
},
};

View File

@ -143,6 +143,7 @@ describe('CE IssuesListApp component', () => {
signInPath: 'sign/in/path',
groupId: '',
commentTemplatePaths: [],
timeTrackingLimitToHours: false,
};
let defaultQueryResponse = getIssuesQueryResponse;

View File

@ -12,10 +12,11 @@ exports[`Repository last commit component renders commit widget 1`] = `
<div
class="gl-flex gl-h-7 gl-items-center gl-ml-5"
>
<pipeline-ci-status-stub
<ci-icon-stub
class="gl-mr-2"
pipelineid="gid://gitlab/Ci::Pipeline/167"
projectfullpath="gitlab-org/gitlab-foss"
showtooltip="true"
status="[object Object]"
uselink="true"
/>
</div>
<gl-button-group-stub

View File

@ -8,115 +8,54 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import LastCommit from '~/repository/components/last_commit.vue';
import CommitInfo from '~/repository/components/commit_info.vue';
import SignatureBadge from '~/commit/components/signature_badge.vue';
import PipelineCiStatus from '~/vue_shared/components/ci_status/pipeline_ci_status.vue';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import eventHub from '~/repository/event_hub';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import projectPathQuery from '~/repository/queries/project_path.query.graphql';
import pipelineCiStatusUpdatedSubscription from '~/graphql_shared/subscriptions/pipeline_ci_status_updated.subscription.graphql';
import { FORK_UPDATED_EVENT } from '~/repository/constants';
import { mockPipelineStatusUpdatedResponse, createCommitData } from '../mock_data';
let wrapper;
let commitData;
let mockResolver;
const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label');
const findHistoryButton = () => wrapper.findByTestId('last-commit-history');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findStatusBox = () => wrapper.findComponent(SignatureBadge);
const findCommitInfo = () => wrapper.findComponent(CommitInfo);
const findPipeline = () => wrapper.findComponent(PipelineCiStatus);
Vue.use(VueApollo);
const { bindInternalEventDocument } = useMockInternalEventsTracking();
const defaultPipelineEdges = [
{
__typename: 'PipelineEdge',
node: {
__typename: 'Pipeline',
id: 'gid://gitlab/Ci::Pipeline/167',
},
},
];
describe('Repository last commit component', () => {
let wrapper;
let commitData;
let mockResolver;
let apolloProvider;
const createCommitData = ({ pipelineEdges = defaultPipelineEdges, signature = null }) => {
return {
data: {
project: {
__typename: 'Project',
id: 'gid://gitlab/Project/6',
repository: {
__typename: 'Repository',
paginatedTree: {
__typename: 'TreeConnection',
nodes: [
{
__typename: 'Tree',
lastCommit: {
__typename: 'Commit',
id: 'gid://gitlab/CommitPresenter/123456789',
sha: '123456789',
title: 'Commit title',
titleHtml: 'Commit title',
descriptionHtml: '',
message: '',
webPath: '/commit/123',
authoredDate: '2019-01-01',
authorName: 'Test',
authorGravatar: 'https://test.com',
author: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Test',
avatarUrl: 'https://test.com',
webPath: '/test',
},
signature,
pipelines: {
__typename: 'PipelineConnection',
edges: pipelineEdges,
},
},
},
],
},
const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label');
const findHistoryButton = () => wrapper.findByTestId('last-commit-history');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findStatusBox = () => wrapper.findComponent(SignatureBadge);
const findCommitInfo = () => wrapper.findComponent(CommitInfo);
const findPipelineStatus = () => wrapper.findComponent(CiIcon);
const subscriptionHandler = jest.fn().mockResolvedValue(mockPipelineStatusUpdatedResponse);
const createComponent = (data = {}, pipelineSubscriptionHandler = subscriptionHandler) => {
const currentPath = 'path';
commitData = createCommitData(data);
mockResolver = jest.fn().mockResolvedValue(commitData);
apolloProvider = createMockApollo([
[pathLastCommitQuery, mockResolver],
[pipelineCiStatusUpdatedSubscription, pipelineSubscriptionHandler],
]);
wrapper = shallowMountExtended(LastCommit, {
apolloProvider,
propsData: { currentPath, historyUrl: '/history' },
provide: {
glFeatures: {
ciPipelineStatusRealtime: true,
},
},
},
});
};
};
const createComponent = (data = {}) => {
Vue.use(VueApollo);
const currentPath = 'path';
commitData = createCommitData(data);
mockResolver = jest.fn().mockResolvedValue(commitData);
const apolloProvider = createMockApollo([[pathLastCommitQuery, mockResolver]]);
apolloProvider.clients.defaultClient.cache.writeQuery({
query: projectPathQuery,
data: {
projectPath: 'gitlab-org/gitlab-foss',
},
});
wrapper = shallowMountExtended(LastCommit, {
apolloProvider,
propsData: { currentPath, historyUrl: '/history' },
provide: {
glFeatures: {
ciPipelineStatusRealtime: false,
},
},
});
};
afterEach(() => {
mockResolver = null;
});
describe('Repository last commit component', () => {
it.each`
loading | label
${true} | ${'shows'}
@ -182,7 +121,7 @@ describe('Repository last commit component', () => {
await waitForPromises();
expect(findPipeline().exists()).toBe(false);
expect(findPipelineStatus().exists()).toBe(false);
});
it('renders pipeline components when pipeline exists', async () => {
@ -190,7 +129,7 @@ describe('Repository last commit component', () => {
await waitForPromises();
expect(findPipeline().exists()).toBe(true);
expect(findPipelineStatus().exists()).toBe(true);
});
describe('created', () => {
@ -232,4 +171,16 @@ describe('Repository last commit component', () => {
expect(findStatusBox().props()).toMatchObject({ signature: signatureResponse });
});
describe('subscription', () => {
it('calls subscription with correct variables', async () => {
createComponent();
await waitForPromises();
expect(subscriptionHandler).toHaveBeenCalledWith({
pipelineId: 'gid://gitlab/Ci::Pipeline/167',
});
});
});
});

View File

@ -282,3 +282,86 @@ export const currentUserDataMock = {
};
export const applicationInfoMock = { gitpodEnabled: true };
export const mockPipelineStatusUpdatedResponse = {
data: {
ciPipelineStatusUpdated: {
id: 'gid://gitlab/Ci::Pipeline/1257',
retryable: false,
cancelable: false,
__typename: 'Pipeline',
detailedStatus: {
detailsPath: '/root/simple-ci-project/-/pipelines/1257',
icon: 'status_success',
id: 'success-1255-1255',
label: 'passed',
text: 'Passed',
__typename: 'DetailedStatus',
},
},
},
};
const defaultPipelineEdges = [
{
__typename: 'PipelineEdge',
node: {
__typename: 'Pipeline',
id: 'gid://gitlab/Ci::Pipeline/167',
detailedStatus: {
__typename: 'DetailedStatus',
id: 'id',
detailsPath: 'https://test.com/pipeline',
icon: 'status_running',
text: 'failed',
},
},
},
];
export const createCommitData = ({ pipelineEdges = defaultPipelineEdges, signature = null }) => {
return {
data: {
project: {
__typename: 'Project',
id: 'gid://gitlab/Project/6',
repository: {
__typename: 'Repository',
paginatedTree: {
__typename: 'TreeConnection',
nodes: [
{
__typename: 'Tree',
lastCommit: {
__typename: 'Commit',
id: 'gid://gitlab/CommitPresenter/123456789',
sha: '123456789',
title: 'Commit title',
titleHtml: 'Commit title',
descriptionHtml: '',
message: '',
webPath: '/commit/123',
authoredDate: '2019-01-01',
authorName: 'Test',
authorGravatar: 'https://test.com',
author: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
name: 'Test',
avatarUrl: 'https://test.com',
webPath: '/test',
},
signature,
pipelines: {
__typename: 'PipelineConnection',
edges: pipelineEdges,
},
},
},
],
},
},
},
},
};
};

View File

@ -3,7 +3,9 @@ import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Reviewers from '~/sidebar/components/reviewers/reviewers.vue';
import SidebarReviewers from '~/sidebar/components/reviewers/sidebar_reviewers.vue';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator';
@ -13,6 +15,7 @@ import Mock from '../../mock_data';
jest.mock('~/super_sidebar/user_counts_fetch');
const { bindInternalEventDocument } = useMockInternalEventsTracking();
Vue.use(VueApollo);
describe('sidebar reviewers', () => {
@ -20,10 +23,12 @@ describe('sidebar reviewers', () => {
let wrapper;
let mediator;
let axiosMock;
let trackEventSpy;
const findAssignButton = () => wrapper.findByTestId('sidebar-reviewers-assign-button');
const findReviewers = () => wrapper.findComponent(Reviewers);
const createComponent = (props) => {
const createComponent = ({ props, stubs, data } = {}) => {
wrapper = shallowMountExtended(SidebarReviewers, {
apolloProvider: apolloMock,
propsData: {
@ -35,6 +40,11 @@ describe('sidebar reviewers', () => {
changing: false,
...props,
},
data() {
return {
...data,
};
},
provide: {
projectPath: 'projectPath',
issuableId: 1,
@ -43,10 +53,13 @@ describe('sidebar reviewers', () => {
},
stubs: {
ApprovalSummary: true,
...stubs,
},
// Attaching to document is required because this component emits something from the parent element :/
attachTo: document.body,
});
({ trackEventSpy } = bindInternalEventDocument(wrapper.element));
};
beforeEach(() => {
@ -69,29 +82,9 @@ describe('sidebar reviewers', () => {
${'shows'} | ${true} | ${true}
${'does not show'} | ${false} | ${false}
`('$copy Assign button when canUpdate is $canUpdate', ({ canUpdate, expected }) => {
wrapper = shallowMountExtended(SidebarReviewers, {
apolloProvider: apolloMock,
propsData: {
issuableIid: '1',
issuableId: 1,
mediator,
field: '',
projectPath: 'projectPath',
changing: false,
},
data() {
return {
issuable: { userPermissions: { adminMergeRequest: canUpdate } },
};
},
provide: {
projectPath: 'projectPath',
issuableId: 1,
issuableIid: 1,
multipleApprovalRulesAvailable: false,
},
stubs: {
ApprovalSummary: true,
createComponent({
data: {
issuable: { userPermissions: { adminMergeRequest: canUpdate } },
},
});
@ -119,15 +112,36 @@ describe('sidebar reviewers', () => {
expect(fetchUserCounts).toHaveBeenCalled();
});
it('calls the mediator when "reviewBySelf" method is called', () => {
createComponent();
describe('assign yourself', () => {
it('tracks how many times the Reviewers component indicates the user is assigning themself', async () => {
createComponent({
data: {
issuable: { userPermissions: { adminMergeRequest: true } },
},
stubs: {
Reviewers,
},
});
expect(mediator.addSelfReview).not.toHaveBeenCalled();
expect(mediator.store.reviewers.length).toBe(0);
// Wait for Apollo to finish so the sidebar is enabled
await nextTick();
wrapper.vm.reviewBySelf();
const reviewers = findReviewers();
reviewers.vm.assignSelf();
expect(mediator.addSelfReview).toHaveBeenCalled();
expect(mediator.store.reviewers.length).toBe(1);
expect(trackEventSpy).toHaveBeenCalledWith('assign_self_as_reviewer_in_mr', {}, undefined);
});
it('calls the mediator when "reviewBySelf" method is called', () => {
createComponent();
expect(mediator.addSelfReview).not.toHaveBeenCalled();
expect(mediator.store.reviewers.length).toBe(0);
wrapper.vm.reviewBySelf();
expect(mediator.addSelfReview).toHaveBeenCalled();
expect(mediator.store.reviewers.length).toBe(1);
});
});
});

View File

@ -126,6 +126,22 @@ describe('NamespaceStorageApp', () => {
});
describe('Namespace project list', () => {
describe('when customSortKey is set', () => {
it('disables sorting', () => {
createComponent({ provide: { customSortKey: 'custom' } });
expect(findProjectList().props('enableSortableFields')).toBe(false);
});
});
describe('when customSortKey is not set', () => {
it('enables sorting', () => {
createComponent({ provide: { customSortKey: undefined } });
expect(findProjectList().props('enableSortableFields')).toBe(true);
});
});
it('renders the 2 projects', async () => {
createComponent();
await waitForPromises();
@ -187,6 +203,47 @@ describe('NamespaceStorageApp', () => {
});
});
describe('sorting projects', () => {
beforeEach(() => {
createComponent();
});
describe.each`
sortBy | sortDesc | sortKey
${'storage'} | ${true} | ${'STORAGE_SIZE_DESC'}
${'storage'} | ${false} | ${'STORAGE_SIZE_ASC'}
${'repository'} | ${true} | ${'REPOSITORY_SIZE_DESC'}
${'repository'} | ${false} | ${'REPOSITORY_SIZE_ASC'}
${'snippets'} | ${true} | ${'SNIPPETS_SIZE_DESC'}
${'snippets'} | ${false} | ${'SNIPPETS_SIZE_ASC'}
${'buildArtifacts'} | ${true} | ${'BUILD_ARTIFACTS_SIZE_DESC'}
${'buildArtifacts'} | ${false} | ${'BUILD_ARTIFACTS_SIZE_ASC'}
${'lfsObjects'} | ${true} | ${'LFS_OBJECTS_SIZE_DESC'}
${'lfsObjects'} | ${false} | ${'LFS_OBJECTS_SIZE_ASC'}
${'packages'} | ${true} | ${'PACKAGES_SIZE_DESC'}
${'packages'} | ${false} | ${'PACKAGES_SIZE_ASC'}
${'wiki'} | ${true} | ${'WIKI_SIZE_DESC'}
${'wiki'} | ${false} | ${'WIKI_SIZE_ASC'}
${'snippets'} | ${true} | ${'SNIPPETS_SIZE_DESC'}
${'snippets'} | ${false} | ${'SNIPPETS_SIZE_ASC'}
${'containerRegistry'} | ${true} | ${'CONTAINER_REGISTRY_SIZE_DESC'}
${'containerRegistry'} | ${false} | ${'CONTAINER_REGISTRY_SIZE_ASC'}
`(
'when sorting changed to $sortBy and sortDesc is $sortDesc',
({ sortBy, sortDesc, sortKey }) => {
it('forms correct sorting order string', async () => {
findProjectList().vm.$emit('sortChanged', { sortBy, sortDesc });
await waitForPromises();
expect(getProjectListStorageHandler).toHaveBeenCalledWith(
expect.objectContaining({ sortKey }),
);
});
},
);
});
describe('projects table pagination component', () => {
const projectsStorageWithPageInfo = cloneDeep(mockGetProjectListStorageGraphQLResponse);
projectsStorageWithPageInfo.data.namespace.projects.pageInfo.hasNextPage = true;

View File

@ -27,7 +27,7 @@ const createComponent = ({ provide = {}, props = {} } = {}) => {
helpLinks: storageTypeHelpPaths,
isLoading: false,
sortBy: 'storage',
sortableFields: { storage: false },
enableSortableFields: false,
...props,
},
});
@ -61,18 +61,34 @@ describe('ProjectList', () => {
it('will allow sorting for fields that have sorting enabled', () => {
createComponent({
props: {
sortableFields: { storage: true },
enableSortableFields: true,
},
});
expect(findTable().props('fields')).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'storage', sortable: true })]),
expect.arrayContaining([
expect.objectContaining({ key: 'storage', sortable: true }),
expect.objectContaining({ key: 'repository', sortable: true }),
expect.objectContaining({ key: 'buildArtifacts', sortable: true }),
expect.objectContaining({ key: 'lfsObjects', sortable: true }),
expect.objectContaining({ key: 'packages', sortable: true }),
expect.objectContaining({ key: 'wiki', sortable: true }),
expect.objectContaining({ key: 'containerRegistry', sortable: true }),
]),
);
});
it('will disable sorting by storage field', () => {
createComponent();
expect(findTable().props('fields')).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'storage', sortable: false })]),
expect.arrayContaining([
expect.objectContaining({ key: 'storage', sortable: false }),
expect.objectContaining({ key: 'repository', sortable: false }),
expect.objectContaining({ key: 'buildArtifacts', sortable: false }),
expect.objectContaining({ key: 'lfsObjects', sortable: false }),
expect.objectContaining({ key: 'packages', sortable: false }),
expect.objectContaining({ key: 'wiki', sortable: false }),
expect.objectContaining({ key: 'containerRegistry', sortable: false }),
]),
);
});
});

View File

@ -1,11 +1,10 @@
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import autoMergeEnabledComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue';
import { MWPS_MERGE_STRATEGY, MWCP_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import { MWCP_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
@ -70,7 +69,7 @@ const defaultMrProps = () => ({
sha,
targetBranchPath,
targetBranch,
autoMergeStrategy: MWPS_MERGE_STRATEGY,
autoMergeStrategy: MWCP_MERGE_STRATEGY,
});
const getStatusText = () => wrapper.findByTestId('statusText').text();
@ -99,10 +98,10 @@ describe('MRWidgetAutoMergeEnabled', () => {
describe('computed', () => {
describe('cancelButtonText', () => {
it('should return "Cancel" if MWPS is selected', () => {
it('should return "Cancel" if MWCP is selected', () => {
factory({
...defaultMrProps(),
autoMergeStrategy: MWPS_MERGE_STRATEGY,
autoMergeStrategy: MWCP_MERGE_STRATEGY,
});
expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe('Cancel auto-merge');
@ -148,15 +147,6 @@ describe('MRWidgetAutoMergeEnabled', () => {
expect(wrapper.find('.js-cancel-auto-merge').props('loading')).toBe(true);
});
it('should render the status text as "...to merged automatically" if MWPS is selected', () => {
factory({
...defaultMrProps(),
autoMergeStrategy: MWPS_MERGE_STRATEGY,
});
expect(getStatusText()).toContain('to be merged automatically when the pipeline succeeds');
});
it('should render the status text as "to be merged automatically..." if MWCP is selected', () => {
factory({
...defaultMrProps(),
@ -165,16 +155,5 @@ describe('MRWidgetAutoMergeEnabled', () => {
expect(getStatusText()).toContain('to be merged automatically when all merge checks pass');
});
it('should render the cancel button as "Cancel" if MWPS is selected', () => {
factory({
...defaultMrProps(),
autoMergeStrategy: MWPS_MERGE_STRATEGY,
});
const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text());
expect(cancelButtonText).toBe('Cancel auto-merge');
});
});
});

View File

@ -15,7 +15,7 @@ import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/
import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue';
import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue';
import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue';
import { MWPS_MERGE_STRATEGY, MWCP_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import { MWCP_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
@ -55,8 +55,8 @@ const createTestMr = (customConfig) => {
shouldRemoveSourceBranch: true,
canRemoveSourceBranch: false,
targetBranch: 'main',
preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY,
availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY],
preferredAutoMergeStrategy: MWCP_MERGE_STRATEGY,
availableAutoMergeStrategies: [MWCP_MERGE_STRATEGY],
mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs',
transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition),
translateStateToMachine: () => this.transitionStateMachine(),
@ -216,31 +216,24 @@ describe('ReadyToMerge', () => {
expect(findMergeButton().text()).toBe('Merge');
});
it('should return Set to auto-merge in the button and Merge when pipeline succeeds in the helper text', () => {
createComponent({ mr: { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY } });
expect(findMergeButton().text()).toBe('Set to auto-merge');
expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds');
});
it('should return Set to auto-merge in the button and Merge when checks pass in the helper text', () => {
createComponent({ mr: { preferredAutoMergeStrategy: MWCP_MERGE_STRATEGY } });
expect(findMergeButton().text()).toBe('Set to auto-merge');
expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds');
expect(findMergeHelperText().text()).toBe('Merge when all merge checks pass');
});
it('should show merge help text when pipeline has failed and has an auto merge strategy', () => {
createComponent({
mr: {
pipeline: { status: 'FAILED' },
availableAutoMergeStrategies: MWPS_MERGE_STRATEGY,
availableAutoMergeStrategies: MWCP_MERGE_STRATEGY,
hasCI: true,
},
});
expect(findMergeButton().text()).toBe('Set to auto-merge');
expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds');
expect(findMergeHelperText().text()).toBe('Merge when all merge checks pass');
});
});
@ -248,7 +241,7 @@ describe('ReadyToMerge', () => {
it('dropdown should be visible if auto merge is available', () => {
createComponent({
mr: {
availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY],
availableAutoMergeStrategies: [MWCP_MERGE_STRATEGY],
mergeable: true,
headPipeline: { active: false },
onlyAllowMergeIfPipelineSucceeds: false,
@ -274,7 +267,7 @@ describe('ReadyToMerge', () => {
it('dropdown should be hidden if the MR is not mergeable', () => {
createComponent({
mr: {
availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY],
availableAutoMergeStrategies: [MWCP_MERGE_STRATEGY],
mergeable: false,
headPipeline: { active: true },
onlyAllowMergeIfPipelineSucceeds: false,
@ -389,11 +382,11 @@ describe('ReadyToMerge', () => {
);
});
it('should handle merge when pipeline succeeds', async () => {
it('should handle auto merge', async () => {
createComponent({ mr: { shouldRemoveSourceBranch: false } }, true);
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(service, 'merge').mockResolvedValue(response('merge_when_pipeline_succeeds'));
jest.spyOn(service, 'merge').mockResolvedValue(response(MWCP_MERGE_STRATEGY));
findMergeButton().vm.$emit('click');
@ -410,7 +403,7 @@ describe('ReadyToMerge', () => {
expect.objectContaining({
sha: '12345678',
should_remove_source_branch: false,
auto_merge_strategy: 'merge_when_pipeline_succeeds',
auto_merge_strategy: 'merge_when_checks_pass',
}),
);
});

View File

@ -1,37 +0,0 @@
export const mockPipelineStatusResponse = {
data: {
project: {
id: 'gid://gitlab/Project/20',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/1257',
detailedStatus: {
label: 'running',
id: 'running-1257-1257',
icon: 'status_running',
text: 'Running',
detailsPath: '/root/ci-project/-/pipelines/1257',
__typename: 'DetailedStatus',
},
__typename: 'Pipeline',
},
__typename: 'Project',
},
},
};
export const mockPipelineStatusUpdatedResponse = {
data: {
ciPipelineStatusUpdated: {
id: 'gid://gitlab/Ci::Pipeline/1257',
__typename: 'Pipeline',
detailedStatus: {
detailsPath: '/root/simple-ci-project/-/pipelines/1257',
icon: 'status_success',
id: 'success-1255-1255',
label: 'passed',
text: 'Passed',
__typename: 'DetailedStatus',
},
},
},
};

View File

@ -1,146 +0,0 @@
import Vue from 'vue';
import { GlLoadingIcon } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import { captureException } from '~/sentry/sentry_browser_wrapper';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import PipelineCiStatus from '~/vue_shared/components/ci_status/pipeline_ci_status.vue';
import pipelineCiStatusQuery from '~/vue_shared/components/ci_status/graphql/pipeline_ci_status.query.graphql';
import pipelineCiStatusUpdatedSubscription from '~/vue_shared/components/ci_status/graphql/pipeline_ci_status_updated.subscription.graphql';
import { mockPipelineStatusResponse, mockPipelineStatusUpdatedResponse } from './mock_data';
Vue.use(VueApollo);
jest.mock('~/alert');
jest.mock('~/sentry/sentry_browser_wrapper');
describe('Pipeline CI Status', () => {
let wrapper;
let mockedSubscription;
let apolloProvider;
const defaultProps = {
pipelineId: 'gid://gitlab/Ci::Pipeline/1255',
projectFullPath: 'gitlab-org/gitlab',
canSubscribe: true,
};
const error = new Error('GraphQL error');
const successResolver = jest.fn().mockResolvedValue(mockPipelineStatusResponse);
const errorResolver = jest.fn().mockRejectedValue(error);
const createMockApolloProvider = (queryResolver) => {
const requestHandlers = [[pipelineCiStatusQuery, queryResolver]];
return createMockApollo(requestHandlers);
};
const createComponent = (props = {}, queryResolver = successResolver) => {
mockedSubscription = createMockApolloSubscription();
apolloProvider = createMockApolloProvider(queryResolver);
apolloProvider.defaultClient.setRequestHandler(
pipelineCiStatusUpdatedSubscription,
() => mockedSubscription,
);
wrapper = shallowMount(PipelineCiStatus, {
propsData: {
...defaultProps,
...props,
},
apolloProvider,
});
};
const findIcon = () => wrapper.findComponent(CiIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
describe('loading', () => {
it('displays loading icon while fetching pipeline status', () => {
createComponent();
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('default', () => {
it('does not display loading icon', async () => {
createComponent();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays pipeline status', async () => {
createComponent();
await waitForPromises();
expect(findIcon().exists()).toBe(true);
});
it('calls pipeline status query correctly', async () => {
createComponent();
await waitForPromises();
expect(successResolver).toHaveBeenCalledWith({
fullPath: 'gitlab-org/gitlab',
pipelineId: 'gid://gitlab/Ci::Pipeline/1255',
});
});
it('updates status when subscription updates', async () => {
createComponent();
await waitForPromises();
expect(findIcon().props('status')).toStrictEqual({
__typename: 'DetailedStatus',
detailsPath: '/root/ci-project/-/pipelines/1257',
icon: 'status_running',
id: 'running-1257-1257',
label: 'running',
text: 'Running',
});
mockedSubscription.next(mockPipelineStatusUpdatedResponse);
await waitForPromises();
expect(findIcon().props('status')).toStrictEqual({
__typename: 'DetailedStatus',
detailsPath: '/root/simple-ci-project/-/pipelines/1257',
icon: 'status_success',
id: 'success-1255-1255',
label: 'passed',
text: 'Passed',
});
});
});
describe('error', () => {
beforeEach(async () => {
createComponent({}, errorResolver);
await waitForPromises();
});
it('shows an error', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred fetching the pipeline status.',
});
});
it('reports an error', () => {
expect(captureException).toHaveBeenCalledWith(error);
});
});
});

View File

@ -17,7 +17,12 @@ describe('WorkItemTimeTracking component', () => {
const findTimeTrackingBody = () => wrapper.findByTestId('time-tracking-body');
const getProgressBarTooltip = () => getBinding(findProgressBar().element, 'gl-tooltip');
const createComponent = ({ canUpdate = true, timeEstimate = 0, totalTimeSpent = 0 } = {}) => {
const createComponent = ({
canUpdate = true,
timeEstimate = 0,
totalTimeSpent = 0,
provide = {},
} = {}) => {
wrapper = shallowMountExtended(WorkItemTimeTracking, {
directives: {
GlModal: createMockDirective('gl-modal'),
@ -33,6 +38,8 @@ describe('WorkItemTimeTracking component', () => {
},
provide: {
fullPath: 'gitlab-org/gitlab',
timeTrackingLimitToHours: false,
...provide,
},
stubs: {
GlSprintf,
@ -186,6 +193,21 @@ describe('WorkItemTimeTracking component', () => {
});
});
describe('when global time tracking hours only preference is turned on', () => {
beforeEach(() => {
createComponent({
timeEstimate: 0,
totalTimeSpent: 60 * 60 * 24 * 3 + 60 * 60 * 3,
provide: { timeTrackingLimitToHours: true },
});
});
it('shows time spent in hours', () => {
expect(findTimeTrackingBody().text()).toMatchInterpolatedText('Spent 75h Add estimate');
expect(findProgressBar().exists()).toBe(false);
});
});
describe('when user has no permissions', () => {
it('does not show "Add time entry" button', () => {
createComponent({ canUpdate: false });

View File

@ -124,6 +124,7 @@ describeSkipVue3(skipReason, () => {
isSignedIn: true,
workItemType: null,
hasIssueDateFilterFeature: false,
timeTrackingLimitToHours: false,
...provide,
},
propsData: {

View File

@ -2,8 +2,8 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiInputsValueInputType'], feature_category: :pipeline_composition do
specify { expect(described_class.graphql_name).to eq('CiInputsValueInputType') }
RSpec.describe GitlabSchema.types['CiInputsValue'], feature_category: :pipeline_composition do
specify { expect(described_class.graphql_name).to eq('CiInputsValue') }
describe '.coerce_input' do
subject(:coerce_input) { described_class.coerce_isolated_input(value) }

View File

@ -240,7 +240,8 @@ RSpec.describe IssuesHelper, feature_category: :team_planning do
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
rss_path: '#',
show_new_issue_link: 'true',
sign_in_path: new_user_session_path
sign_in_path: new_user_session_path,
time_tracking_limit_to_hours: "false"
}
expect(helper.project_issues_list_data(project, current_user)).to include(expected)
@ -298,7 +299,8 @@ RSpec.describe IssuesHelper, feature_category: :team_planning do
new_project_path: new_project_path(namespace_id: group.id),
rss_path: '#',
sign_in_path: new_user_session_path,
group_id: group.id
group_id: group.id,
time_tracking_limit_to_hours: "false"
}
expect(helper.group_issues_list_data(group, current_user)).to include(expected)

Some files were not shown because too many files have changed in this diff Show More