Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-07-03 03:18:20 +00:00
parent bddd730eaa
commit a400b7b7ff
33 changed files with 669 additions and 248 deletions

View File

@ -1336,12 +1336,6 @@ lib/gitlab/checks/**
/app/services/projects/refresh_build_artifacts_size_statistics_service.rb
/app/uploaders/job_artifact_uploader.rb
/app/validators/json_schemas/build_metadata_id_tokens.json
/app/views/projects/artifacts/
/app/views/projects/generic_commit_statuses/
/app/views/projects/jobs/
/app/views/projects/pipeline_schedules/
/app/views/projects/pipelines/
/app/views/projects/triggers/
/app/workers/build_queue_worker.rb
/app/workers/ci_platform_metrics_update_cron_worker.rb
/app/workers/create_pipeline_worker.rb
@ -1408,6 +1402,12 @@ lib/gitlab/checks/**
/**/javascripts/admin/application_settings/runner_token_expiration/
/**/javascripts/editor/schema/ci.json
/app/**/ci/*.haml
/app/views/projects/artifacts/
/app/views/projects/generic_commit_statuses/
/app/views/projects/jobs/
/app/views/projects/pipeline_schedules/
/app/views/projects/pipelines/
/app/views/projects/triggers/
/ee/app/**/ci/*.haml
/ee/app/**/merge_trains/*.haml

View File

@ -84,6 +84,7 @@ export async function mountIssuesListApp() {
emailsHelpPagePath,
exportCsvPath,
fullPath,
groupId,
groupPath,
hasAnyIssues,
hasAnyProjects,
@ -113,10 +114,10 @@ export async function mountIssuesListApp() {
rssPath,
showNewIssueLink,
signInPath,
groupId = '',
reportAbusePath,
registerPath,
issuesListPath,
wiCanAdminLabel,
wiIssuesListPath,
wiLabelsManagePath,
wiReportAbusePath,
} = el.dataset;
return new Vue({
@ -155,10 +156,8 @@ export async function mountIssuesListApp() {
canReadCrmContact: parseBoolean(canReadCrmContact),
canReadCrmOrganization: parseBoolean(canReadCrmOrganization),
fullPath,
projectPath: fullPath,
groupId,
groupPath,
reportAbusePath,
registerPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
@ -197,8 +196,11 @@ export async function mountIssuesListApp() {
markdownHelpPath,
quickActionsHelpPath,
resetPath,
groupId,
issuesListPath,
// For work item modal
canAdminLabel: wiCanAdminLabel,
issuesListPath: wiIssuesListPath,
labelsManagePath: wiLabelsManagePath,
reportAbusePath: wiReportAbusePath,
},
render: (createComponent) => createComponent(IssuesListApp),
});

View File

@ -34,10 +34,6 @@ import TaskListItemActions from './task_list_item_actions.vue';
Vue.use(GlToast);
const workItemTypes = {
TASK: 'task',
};
export default {
directives: {
SafeHtml,
@ -146,19 +142,14 @@ export default {
this.initialUpdate = false;
}
this.$nextTick(() => {
this.renderGFM();
});
this.renderGFM();
},
},
mounted() {
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
// this.renderGFM();
this.$nextTick(() => {
this.renderGFM();
});
this.renderGFM();
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
@ -167,7 +158,9 @@ export default {
this.removeAllPointerEventListeners();
},
methods: {
renderGFM() {
async renderGFM() {
await this.$nextTick();
renderGFM(this.$refs['gfm-content']);
if (this.canUpdate) {
@ -177,15 +170,13 @@ export default {
fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description',
onUpdate: this.taskListUpdateStarted.bind(this),
onSuccess: this.taskListUpdateSuccess.bind(this),
onUpdate: () => this.$emit('taskListUpdateStarted'),
onSuccess: () => this.$emit('taskListUpdateSucceeded'),
onError: this.taskListUpdateError.bind(this),
});
this.removeAllPointerEventListeners();
this.renderSortableLists();
this.renderTaskListItemActions();
}
},
@ -263,30 +254,18 @@ export default {
this.pointerEventListeners.delete(listItem);
});
},
taskListUpdateStarted() {
this.$emit('taskListUpdateStarted');
},
taskListUpdateSuccess() {
this.$emit('taskListUpdateSucceeded');
},
taskListUpdateError() {
createAlert({
message: sprintf(
__(
'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
),
{
issueType: this.issuableType,
},
),
});
const message = __(
'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
);
createAlert({ message: sprintf(message, { issueType: this.issuableType }) });
this.$emit('taskListUpdateFailed');
},
createTaskListItemActions(provide) {
createTaskListItemActions() {
const app = new Vue({
el: document.createElement('div'),
provide,
provide: { issuableType: this.issuableType },
render: (createElement) => createElement(TaskListItemActions),
});
return app.$el;
@ -310,8 +289,7 @@ export default {
);
taskListItems?.forEach((item) => {
const provide = { canUpdate: this.canUpdate, issuableType: this.issuableType };
const dropdown = this.createTaskListItemActions(provide);
const dropdown = this.createTaskListItemActions();
this.insertNextToTaskListItemText(dropdown, item);
this.addPointerEventListeners(item, '.task-list-item-actions');
this.hasTaskListItemActions = true;
@ -419,7 +397,7 @@ export default {
},
showAlert(message, error) {
createAlert({
message: sprintfWorkItem(message, workItemTypes.TASK),
message: sprintfWorkItem(message, WORK_ITEM_TYPE_VALUE_TASK),
error,
captureError: true,
});

View File

@ -1,20 +1,14 @@
<script>
import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { __, s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
i18n: {
convertToTask: s__('WorkItem|Convert to task'),
delete: __('Delete'),
taskActions: s__('WorkItem|Task actions'),
},
components: {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
},
inject: ['canUpdate', 'issuableType'],
inject: ['issuableType'],
computed: {
showConvertToTaskItem() {
return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType);
@ -33,29 +27,28 @@ export default {
<template>
<gl-disclosure-dropdown
v-if="canUpdate"
class="task-list-item-actions-wrapper"
category="tertiary"
icon="ellipsis_v"
no-caret
placement="bottom-end"
:toggle-text="$options.i18n.taskActions"
text-sr-only
toggle-class="task-list-item-actions gl-opacity-0 gl-p-2! "
toggle-class="task-list-item-actions gl-opacity-0 !gl-p-2"
:toggle-text="s__('WorkItem|Task actions')"
>
<gl-disclosure-dropdown-item
v-if="showConvertToTaskItem"
class="gl-ml-2!"
class="!gl-ml-2"
data-testid="convert"
@action="convertToTask"
>
<template #list-item>
{{ $options.i18n.convertToTask }}
{{ s__('WorkItem|Convert to task') }}
</template>
</gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item class="gl-ml-2!" data-testid="delete" @action="deleteTaskListItem">
<gl-disclosure-dropdown-item class="!gl-ml-2" data-testid="delete" @action="deleteTaskListItem">
<template #list-item>
<span class="gl-text-red-500!">{{ $options.i18n.delete }}</span>
<span class="gl-text-red-500">{{ __('Delete') }}</span>
</template>
</gl-disclosure-dropdown-item>
</gl-disclosure-dropdown>

View File

@ -66,23 +66,23 @@ export default {
<template>
<div>
<div
class="suggested-colors gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-gap-2"
class="suggested-colors gl-grid gl-grid-cols-[repeat(auto-fill,2rem)] gl-justify-between gl-gap-2"
>
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
v-gl-tooltip:tooltipcontainer
class="gl-block color-palette"
class="gl-block gl-h-7 gl-w-7 gl-rounded-base"
:style="getStyle(color)"
:title="getColorName(color)"
@click.prevent="handleColorClick(getColorCode(color))"
/>
</div>
<div class="gl-display-flex">
<gl-form-group class="gl-mb-0!">
<div class="gl-flex">
<gl-form-group class="gl-mb-0">
<gl-form-input
v-model.trim="selectedColor"
class="-gl-mr-1 gl-mb-2 gl-w-8"
class="-gl-mr-1 gl-w-8 gl-rounded-e-none"
type="color"
:value="selectedColor"
:placeholder="__('Select color')"
@ -92,11 +92,11 @@ export default {
<gl-form-group
:invalid-feedback="errorMessage"
:state="validColor"
class="gl-mb-0! gl-flex-grow-1"
class="gl-mb-0 gl-flex-grow-1"
>
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
class="gl-rounded-s-none gl-mb-2"
:placeholder="__('Use custom color #FF0000')"
:autofocus="autofocus"
:state="validColor"

View File

@ -1,5 +1,6 @@
<script>
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
import NO_USERS_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-user-settings-md.svg';
import { GlSkeletonLoader, GlTable, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale';
import UserDate from '~/vue_shared/components/user_date.vue';
import UserAvatar from './user_avatar.vue';
@ -10,6 +11,7 @@ export default {
GlTable,
UserAvatar,
UserDate,
GlEmptyState,
},
props: {
users: {
@ -63,47 +65,50 @@ export default {
thClass: 'gl-w-2/20',
},
],
NO_USERS_SVG,
};
</script>
<template>
<div>
<gl-table
:items="users"
:fields="$options.fields"
:empty-text="s__('AdminUsers|No users found')"
show-empty
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'user-row-content' }"
>
<template #cell(name)="{ item: user }">
<user-avatar :user="user" :admin-user-path="adminUserPath" />
</template>
<gl-table
v-if="users.length > 0"
:items="users"
:fields="$options.fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'user-row-content' }"
>
<template #cell(name)="{ item: user }">
<user-avatar :user="user" :admin-user-path="adminUserPath" />
</template>
<template #cell(createdAt)="{ item: { createdAt } }">
<user-date :date="createdAt" />
</template>
<template #cell(createdAt)="{ item: { createdAt } }">
<user-date :date="createdAt" />
</template>
<template #cell(lastActivityOn)="{ item: { lastActivityOn } }">
<user-date :date="lastActivityOn" show-never />
</template>
<template #cell(lastActivityOn)="{ item: { lastActivityOn } }">
<user-date :date="lastActivityOn" show-never />
</template>
<template #cell(groupCount)="{ item: { id } }">
<div :data-testid="`user-group-count-${id}`">
<gl-skeleton-loader v-if="groupCountsLoading" :width="40" :lines="1" />
<span v-else>{{ groupCounts[id] || 0 }}</span>
</div>
</template>
<template #cell(groupCount)="{ item: { id } }">
<div :data-testid="`user-group-count-${id}`">
<gl-skeleton-loader v-if="groupCountsLoading" :width="40" :lines="1" />
<span v-else>{{ groupCounts[id] || 0 }}</span>
</div>
</template>
<template #cell(projectsCount)="{ item: { id, projectsCount } }">
<div :data-testid="`user-project-count-${id}`">
{{ projectsCount || 0 }}
</div>
</template>
<template #cell(projectsCount)="{ item: { id, projectsCount } }">
<div :data-testid="`user-project-count-${id}`">
{{ projectsCount || 0 }}
</div>
</template>
<template #cell(settings)="{ item: user }">
<slot name="user-actions" :user="user"></slot>
</template>
</gl-table>
</div>
<template #cell(settings)="{ item: user }">
<slot name="user-actions" :user="user"></slot>
</template>
</gl-table>
<gl-empty-state
v-else
:svg-path="$options.NO_USERS_SVG"
:title="s__('AdminUsers|No users found')"
/>
</template>

View File

@ -218,6 +218,7 @@ export default {
<gl-collapsible-listbox
:id="inputId"
ref="listbox"
class="work-item-sidebar-dropdown"
:multiple="multiSelect"
:searchable="searchable"
start-opened
@ -233,7 +234,6 @@ export default {
:selected="localSelectedItem"
:reset-button-label="resetButton"
:infinite-scroll-loading="infiniteScrollLoading"
toggle-class="work-item-sidebar-dropdown-toggle"
@reset="unassignValue"
@search="debouncedSearchKeyUpdate"
@select="handleItemClick"

View File

@ -110,6 +110,12 @@ export default {
allowsScopedLabels() {
return this.labelsWidget?.allowsScopedLabels;
},
createLabelText() {
return this.isGroup ? __('Create group label') : __('Create project label');
},
manageLabelText() {
return this.isGroup ? __('Manage group labels') : __('Manage project labels');
},
workspaceType() {
return this.isGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
@ -282,23 +288,28 @@ export default {
class="!gl-justify-start"
block
category="tertiary"
data-testid="create-project-label"
data-testid="create-label"
@click="showLabelForm = true"
>
{{ __('Create project label') }}
{{ createLabelText }}
</gl-button>
<gl-button
class="!gl-justify-start !gl-mt-2"
block
category="tertiary"
:href="labelsManagePath"
data-testid="manage-project-labels"
data-testid="manage-labels"
>
{{ __('Manage project labels') }}
{{ manageLabelText }}
</gl-button>
</template>
<template v-if="showLabelForm" #body>
<gl-disclosure-dropdown block start-opened :toggle-text="dropdownText">
<gl-disclosure-dropdown
class="work-item-sidebar-dropdown"
block
start-opened
:toggle-text="dropdownText"
>
<div
class="gl-text-sm gl-font-bold gl-leading-24 gl-border-b gl-pt-2 gl-pb-3 gl-pl-4 gl-mb-4"
>

View File

@ -258,7 +258,7 @@ export default {
<gl-collapsible-listbox
id="$options.inputId"
ref="input"
class="gl-block"
class="work-item-sidebar-dropdown gl-block"
data-testid="work-item-parent-listbox"
block
searchable
@ -266,7 +266,6 @@ export default {
is-check-centered
category="primary"
fluid-width
toggle-class="work-item-sidebar-dropdown-toggle"
positioning-strategy="fixed"
:searching="isLoading"
:header-text="$options.i18n.assignParentLabel"

View File

@ -19,13 +19,13 @@
inset-inline-end: -2rem;
}
.task-list-item-actions-wrapper.show .task-list-item-actions,
.task-list-item-actions[aria-expanded="true"],
.task-list-item-actions:is(:focus, :hover) {
opacity: 1;
}
}
.md.has-task-list-item-actions > :is(ul, ol) > li {
.has-task-list-item-actions > :is(ul, ol) > li {
margin-inline-end: 1.5rem;
}
@ -36,10 +36,6 @@
inset-inline-start: -0.6rem;
}
}
.dropdown-item.text-danger p {
color: var(--red-500, $red-500); /* Override typography.scss making text black */
}
}
.is-ghost {

View File

@ -345,12 +345,7 @@ $disclosure-hierarchy-chevron-dimension: 1.2rem;
}
}
/** Ideally should be fixed in gitlab-ui but fixing it using classes for now **/
.work-item-sidebar-dropdown-toggle {
justify-content: start !important;
}
.work-item-sidebar-dropdown-toggle ~ .gl-new-dropdown-panel {
.work-item-sidebar-dropdown .gl-new-dropdown-panel {
width: 100% !important;
max-width: 19rem !important;
}

View File

@ -67,7 +67,7 @@
&:nth-of-type(7) {
border-top-right-radius: $gl-border-radius-base;
}
&:nth-last-child(7) {
border-bottom-left-radius: $gl-border-radius-base;
}
@ -78,11 +78,3 @@
}
}
}
.suggested-colors {
.color-palette {
width: 28px;
height: 28px;
border-radius: 2px;
}
}

View File

@ -136,6 +136,7 @@ module IssuesHelper
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
full_path: namespace.full_path,
has_issue_date_filter_feature: has_issue_date_filter_feature?(namespace, current_user).to_s,
initial_sort: current_user&.user_preference&.issues_sort,
is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
is_public_visibility_restricted:
@ -143,7 +144,7 @@ module IssuesHelper
is_signed_in: current_user.present?.to_s,
rss_path: url_for(safe_params.merge(rss_url_options)),
sign_in_path: new_user_session_path,
has_issue_date_filter_feature: has_issue_date_filter_feature?(namespace, current_user).to_s
wi: work_items_show_data(namespace)
}
end
@ -179,10 +180,7 @@ module IssuesHelper
quick_actions_help_path: help_page_path('user/project/quick_actions'),
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,
report_abuse_path: add_category_abuse_reports_path,
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
issues_list_path: project_issues_path(project)
show_new_issue_link: show_new_issue_link?(project).to_s
)
end
@ -191,11 +189,10 @@ module IssuesHelper
can_create_projects: can?(current_user, :create_projects, group).to_s,
can_read_crm_contact: can?(current_user, :read_crm_contact, group).to_s,
can_read_crm_organization: can?(current_user, :read_crm_organization, group).to_s,
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),
group_id: group.id,
issues_list_path: issues_group_path(group)
new_project_path: new_project_path(namespace_id: group.id)
)
end

View File

@ -19,11 +19,15 @@ module ResourceAccessTokens
access_token.revoke!
destroy_bot_user
success_message = "Access token #{access_token.name} has been revoked"
unless Feature.enabled?(:retain_resource_access_token_user_after_revoke, resource)
destroy_bot_user
success_message += " and the bot user has been scheduled for deletion"
end
log_event
success("Access token #{access_token.name} has been revoked and the bot user has been scheduled for deletion.")
success("#{success_message}.")
rescue StandardError => error
log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}")
error(error.message)

View File

@ -1,4 +1,13 @@
- page_title _("Users")
- add_to_breadcrumbs _("Users"), admin_users_path
- breadcrumb_title _("Cohorts")
- page_title _("Cohorts"), _("Users")
= render ::Layouts::PageHeadingComponent.new(_('Cohorts')) do |c|
- c.with_actions do
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_path) do
= s_('AdminUsers|New user')
= render 'admin/users/tabs'

View File

@ -10,7 +10,6 @@
.gl-display-flex.gl-align-items-flex-start.gl-flex-wrap.gl-md-flex-nowrap.gl-gap-4.row-content-block.gl-border-0{ data: { testid: "filtered-search-block" } }
#js-admin-users-filter-app
.gl-flex-shrink-0
= label_tag s_('AdminUsers|Sort by')
= gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { placement: 'right' }
#js-admin-users-app{ data: admin_users_data_attributes(@users) }

View File

@ -1,13 +1,15 @@
- page_title _("Users")
.top-area{ data: { event_tracking_load: 'true', event_tracking: 'view_admin_users_pageload' } }
= render 'tabs'
.nav-controls
= render ::Layouts::PageHeadingComponent.new(_('Users'), options: { data: { event_tracking_load: 'true', event_tracking: 'view_admin_users_pageload' } }) do |c|
- c.with_actions do
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_path) do
= s_('AdminUsers|New user')
.top-area
= render 'tabs'
.tab-content
.tab-pane.active
= render 'users'

View File

@ -1,5 +1,7 @@
- page_title _("Wiki")
- @right_sidebar = true
- @gfm_form = true
- @noteable_type = 'Wiki'
- add_page_specific_style 'page_bundles/wiki'
- if @error.present?

View File

@ -0,0 +1,9 @@
---
name: retain_resource_access_token_user_after_revoke
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/462217
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157130
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/468606
milestone: '17.2'
group: group::authentication
type: beta
default_enabled: false

View File

@ -2,6 +2,7 @@
# Warning: gitlab.LatinTerms
#
# Checks for use of Latin terms.
# Uses https://github.com/errata-ai/Google/blob/master/Google/Latin.yml for ideas.
#
# For a list of all options, see https://vale.sh/docs/topics/styles/
extends: substitution
@ -11,8 +12,6 @@ level: warning
nonword: true
ignorecase: true
swap:
e\.g\.: for example
e\. g\.: for example
i\.e\.: that is
i\. e\.: that is
via: "with', 'through', or 'by using"
'\b(?:e\.?g[\s.,;:])': for example
'\b(?:i\.?e[\s.,;:])': that is
'\bvia\b': "with', 'through', or 'by using"

View File

@ -97,10 +97,12 @@ Alternatively, you might want to [install the GitLab for Jira Cloud app manually
- The instance must be on GitLab version 15.7 or later.
- You must set up [OAuth authentication](#set-up-oauth-authentication).
- If your instance uses HTTPS, your GitLab certificate must be publicly trusted or contain the full chain certificate.
- Your network must allow inbound and outbound connections between GitLab and Jira. For self-managed instances that are behind a
- Your network must allow inbound and outbound connections between your self-managed instance,
Jira, and GitLab.com. For self-managed instances that are behind a
firewall and cannot be directly accessed from the internet, you must:
1. Set up an internet-facing [reverse proxy](#using-a-reverse-proxy) in front of your self-managed instance.
1. Open your firewall and allow inbound traffic from [Atlassian IP addresses](https://support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/#Outgoing-Connections) only.
1. Add [GitLab IP addresses](../../user/gitlab_com/index.md#ip-range) to the allowlist of your firewall.
- The Jira user that installs and configures the app must meet certain [requirements](#jira-user-requirements).
### Set up your instance

View File

@ -221,7 +221,9 @@ For the second log, you might have one of the following scenarios:
- `json.jira_status_code` and `json.jira_body` might contain the response received from the self-managed instance or a proxy in front of the instance.
- If `json.jira_status_code` is `401 Unauthorized` and `json.jira_body` is `(empty)`:
- [**Jira Connect Proxy URL**](jira_cloud_app.md#set-up-your-instance) might not be set to `https://gitlab.com`.
- The self-managed instance might be blocking outgoing connections. Ensure that the self-managed instance can connect to `connect-install-keys.atlassian.com`.
- The self-managed instance might be blocking outgoing connections. Ensure that your
self-managed instance can connect to both `connect-install-keys.atlassian.com`
and `gitlab.com`.
- The self-managed instance is unable to decrypt the JWT token from Jira. [From GitLab 16.11](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147234),
the [`exceptions_json.log`](../logs/index.md#exceptions_jsonlog) contains more information about the error.
- If a [reverse proxy](jira_cloud_app.md#using-a-reverse-proxy) is in front of your self-managed instance,

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -0,0 +1,290 @@
---
status: proposed
creation-date: "2023-06-03"
authors: [ "@dmishunov" ]
coach: "@jessieay"
approvers: [ ]
owning-stage: "~devops::ai-powered"
participating-stages: ["~devops::create"]
---
# AI Context Management
## Glossary
- **AI Context**. In the scope of this technical blueprint, the term "AI Context" refers to supplementary information
provided to the AI system alongside the primary prompts.
- **AI Context Policy**. The "AI Context Policy" is a user-defined and user-managed mechanism allowing precise
control over the content that can be sent to the AI as contextual information. In the context of this blueprint, the
_AI Context Policy_ is suggested as a YAML configuration file.
- **AI Context Policy Management**. Within this blueprint, "Management" encompasses the user-driven processes of
creating, modifying, and removing AI Context Policies according to specific requirements and preferences.
- **Automatic AI Context**. _AI Context_, retrieved automatically based on the active document. _*Automatic AI Contex_
can be the active document's dependencies (modules, methods, etc., imported into the active document), some
search-based, or other mechanisms over which the user has limited control.
- **Supplementary User Context**: User-defined _AI Context_, such as open tabs in IDEs, local files, and folders, that the user
provides from their local environment to extend the default _AI Context_.
- **AI Context Retriever**: A backend system capable of:
- communicating with _AI Context Policy Management_
- fetching content defined in _Automatic AI Context_ and _Supplementary User Context_ (complete files, definitions,
methods, etc.), based on the _AI Context Policy Management_
- correctly augment the user prompt with AI Context before sending it to LLM. Presumably, this part is already
handled by [AI Gateway](../ai_gateway/index.md).
- **Project Administrator**. In the context of this blueprint, "Project Administrator" means any individual with the
"Edit project settings" permission ("Maintainer" or "Owner" roles, as defined in [Project members permissions](../../../user/permissions.md#project-members-permissions)).
![Illustration of the AI Context architecture](img/architecture.jpg)
## Summary
Correct context can dramatically improve the quality of AI responses. This blueprint aims to accommodate AI Context
seamlessly into our offering by architecting a solution that is ready for this additional context coming from different
AI features.
However, we recognize the importance of security and trust, which automatic solutions do not necessarily provide. To
address any concerns users might have about the content fed into the AI Context, this blueprint suggests providing them
with control and customization options. This way, users can adjust the content according to their preferences and have a
clear understanding of what information is being utilized.
This blueprint proposes a system for managing _AI Context_ at the _Project Administrator_ and individual
user levels. Its goal is to allow _Project Administrator_ to set high-level rules for what content can be included as context for AI
prompts while enabling users to specify _Supplementary User Context_ for their prompts. The global _AI Context Policy_ will use a YAML
configuration file format stored in the same Git repository. The suggested format of the YAML configuration files
is discussed below.
## Motivation
Ensuring the AI has the correct context is crucial for generating accurate and relevant code suggestions or responses.
As the adoption of AI-assisted development grows, it's essential to give organizations and users control over what project
content is sent as context to AI models. Some files or directories may contain sensitive information that should not
be shared. At the same time, users may want to provide additional context for their prompts to get more
relevant suggestions. We need a flexible _AI Context_ management system to handle these cases.
### Goals
### For _Project Administrators_
- Allow _Project Administrators_ set the default _AI Context Policy_ to control whether content can or cannot be
automatically included in the _AI Context_ when making requests to LLMs
- Allow _Project Administrators_ to specify exceptions to the default _AI Context Policy_
- Provide a UI to manage the default _AI Context Policy_ and its exceptions list easily
### For users
- Allow to set _Supplementary User Context_ to include as AI context for their prompts
- Provide a UI to manage _Supplementary User Context_ easily
### Non-Goals
- _AI Context Retriever_ architecture - different environments (Web, IDEs) will probably implement their retrievers.
However, the unified public interface of the retrievers should be considered.
- Extremely granular controls like allowing/excluding individual lines of code
- Storing entire file contents from user projects, only paths will be persisted
## Proposal
The proposed architecture consists of 3 main parts:
- _AI Context Retriever_
- _AI Context Policy Management_
- _Supplementary User Context_
There are several different ongoing efforts related to various implementations of _AI Context Retriever_ both
[for Web](https://gitlab.com/groups/gitlab-org/-/epics/14040), and [for IDEs](https://gitlab.com/groups/gitlab-org/editor-extensions/-/epics/55).
Because of that, the architecture for _AI Context Retriever_ is beyond the scope of this blueprint. However, in the
context of this blueprint, it is assumed that:
- _AI Context Retriever_ is capable of automatically retrieving and fetching _Automatic AI Context_ and passing it
on as _AI Context_ to LLM.
- _AI Context Retriever_ can automatically retrieve and fetch _Supplementary User Context_and pass
it on as _AI Context_ to LLM.
- _AI Context Retriever_ implementation can ensure that any content passed as _AI Context_ to a model
adheres to the global _AI Context Policy_.
- _AI Context Retriever_ can trim the _AI Context_ to meet the contextual window requirement for a
specific LLM used for that or another Duo feature.
### _AI Context Policy Management_ proposal
To implement the _AI Context Policy Management_ system, it is proposed to:
- Introduce the YAML file format for configuring global policies
- In the YAML configuration file, support two `ai_context_policy` types:
- `block`: blocks all content except for the specified `exclude` paths. Excluded files are allowed. (**Default**)
- `allow`: allows all content except for the specified `exclude` paths. Excluded files are blocked.
- `version`: specifies the schema version of the AI context file. Starting with `version: 1`. If omitted treated as the latest version known to the client.
- In the YAML configuration file, support glob patterns to exclude certain paths from the global policy
- Support nested _AI Context Policies_ to provide a more granular control of _AI Context_ in sub-folders. For
example, a policy in `/src/tests` would override a policy in `/src`, which, in its turn, would override a
global _AI Context Policy_ in `/`.
### _Supplementary User Context_ proposal
To implement the _Supplementary User Context_ system, it is proposed to:
- Introduce user-level UI to specify _Supplementary User Context_ for prompts. A particular implementation of the UI could
differ in different environments (IDEs, Web, etc.), but the actual design of these implementations is beyond the scope of
this architecture blueprint
- The user-level UI should communicate to the user what is in the _Supplementary User Context_ at any moment.
- The user-level UI should allow the user to edit the contents of the _Supplementary User Context_.
### Optional steps
- Provide UI for _Project Administrators_ to configure global _AI Context Policy_. [Source Editor](../../../development/fe_guide/source_editor.md)
can be used as the editor for this type of YAML file format, similar to the
[Security Policy Editor](../../../user/application_security/policies/index.md#policy-editor).
- Implement a validation mechanism for _AI Context Policies_ to somehow notify the _Project Administrators_ in case
of the invalid format of the YAML configuration file. It could be a job in CI. But to catch possible issues proactively, it is
also advised to introduce the validation step as part of the
[pre-push static analysis](../../../development/contributing/style_guides.md#pre-push-static-analysis-with-lefthook)
## Design and implementation details
- **YAML Configuration File Format**: The proposed YAML configuration file format for defining the global
_AI Context Policy_ is as follows:
```yaml
ai_context_policy: [allow|block]
exclude:
- glob/**/pattern
```
The `ai_context_policy` section specifies the current policy for this and all underlying folders in a repo.
The `exclude` section specifies the exceptions to the `ai_context_policy`. Technically, it's an inversion of the policy.
For example, if we specify `foo_bar.js` in `exclude`:
- for the `allow` policy, it means that `foo_bar.js` will be blocked
- for the `block` policy, it means that `foo_bar.js` will be allowed
- **User-Level UI for _Supplementary User Context_**: The UI for specifying _Supplementary User Context_ for prompts
can be implemented differently depending on the environment (IDEs, Web, etc.). However, the implementation should
ensure users can provide additional context for their prompts. The specified _Supplementary User Context_ for
each user can be stored as:
- a preference stored in the user profile in GitLab
- **Pros**: Consistent across devices and environments (Web, IDEs, etc.)
- **Cons**: Additional work in the monolith, potentially a lot of new read/writes to a database
- a preference stored in the local IDE/Web storage
- **Pros**: User-centric, local to user environment
- **Cons**: Different implementations for different environments (Web, IDEs, etc.), doesn't survive switching
environment or device
In both cases, the storage should allow the preference to be associated with a particular repository. Factors
like data consistency, performance, and implementation complexity should guide the decision on what type of storage
to use.
- To mitigate potential performance and scalability issues, it would make sense to keep _AI Context Retriever_, and
_AI Context Policy Management_ in the same environment as the feature needing those. It would be
[Language Server](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp) for Duo features in IDEs and different
services in the monolith for Duo features on the Web.
### Data flow
Here's the draft of the data flow demonstrating the role of _AI Context_ using the Code Suggestions feature as an example.
```mermaid
sequenceDiagram
participant CS as Code Suggestions
participant CR as AI Context Retriever
participant PM as AI Context Policy Management
participant LLM as Language Model
CS->>CR: Request Code Suggestion
CR->>CR: Retrieve Supplementary User Context list
CR->>CR: Retrieve Automatic AI Context list
CR->>PM: Check AI Context against Policy
PM-->>CR: Return valid AI Context list
CR->>CR: Fetch valid AI Context
CR->>LLM: Send prompt with final AI Context
LLM->>LLM: Generate code suggestions
LLM-->>CS: Return code suggestions
CS->>CS: Present code suggestions to the user
```
In case the _AI Context Retriever_ fails to fetch any content from the _AI Context_, the prompt is sent with
_AI Context_, which was successfully fetched. In a low-probability case, when _AI Context Retriever_ cannot fetch any content, the prompt should be sent out as-is.
## Alternative solutions
### JSON Configuration Files
- **Pros**: Widely used, easier integration with web technologies.
- **Cons**: Less readable compared to YAML for complex configurations.
### Database-Backed Configuration
- **Pros**: Centralized management, dynamic updates.
- **Cons**: Not version controlled.
### Environment Variables
- **Pros**: Simplifies configuration for deployment and scaling.
- **Cons**: Less suitable for complex configurations.
### Policy as Code (without YAML)
- **Pros**: Better control and auditing with versioned code.
- **Cons**: It requires users to write code and us to invent a language for it.
### Policy in `.ai_ignore` and other Git-like files
- **Pros**: Provides a straightforward approach, identical to the `allow` policy with the list of `exclude` suggested in this blueprint
- **Cons**: Supports only the `allow` policy; the processing of this file type still has to be implemented
Based on these alternatives, the YAML file was chosen as a format for this blueprint because of versioning
in Git, and more versatility compared to the `.ai_ignore` alternative.
## Suggested iterative implementation plan
Please refer to the [Proposal](#proposal) for a detailed explanation of the items in every iteration.
### Iteration 1
- Introduce the global `.ai-context-policy.yaml` YAML configuration file format and schema for this file type
as part of _AI Context Policy Management_.
- _AI Context Retrievers_ introduce support for _Supplementary User Context_.
- Optional: validation mechanism (like CI job and pre-push static analysis) for `.ai-context-policy.yaml`
**Success criteria for the iteration:** Prompts sent from the Code Suggestions feature in IDEs contain
_AI Context_ only with the open IDE tabs, which adhere to the global _AI Context Policy_ in the root of a repository.
### Iteration 2
- In _AI Context Retrievers_ introduce support for _Automatic AI Context_.
- Connect more features to the _AI Context Management_ system.
**Success criteria for the iteration:** Prompts sent from the Code Suggestions feature in IDEs contain _AI Context_
with items of _Automatic AI Context_, which adhere to the global _AI Context Policy_ in the root of a repository.
### Iteration 3
- Connect all Duo features on the Web and in IDEs to _AI Context Retrievers_ and adhere to the global
_AI Context Policy_.
**Success criteria for the iteration:** All Duo features in all environments send _AI Context_ which adheres to the
global _AI Context Policy_
### Iteration 4
- Support nested `.ai-context-policy.yaml` YAML configuration files.
**Success criteria for the iteration:** _AI Context Policy_ placed into the sub-folders of a repository, override
higher-level policies when sending prompts.
### Iteration 5
- User-level UI for _Supplementary User Context_.
**Success criteria for the iteration:** Users can see and edit the contents of the _Supplementary User Context_ and
the context is shared between all Duo features within the environment (Web, IDEs, etc.)
### Iteration 6
- Optional: UI for configuring the global _AI Context Policy_.
**Success criteria for the iteration:** Users can see and edit the contents of the _AI Context Policies_ in a UI
editor.

View File

@ -768,14 +768,13 @@ We want to avoid introducing a changelog when features are not accessible by an
ACF(added / changed / fixed / '...')
RF{Remove flag}
RF2{Remove flag}
NC(No changelog)
RC(removed / changed)
OTHER(other)
FDOFF -->CDO-->ACF
FDOFF -->RF
RF-->|Keep new code?| ACF
RF-->|Keep old code?| NC
RF-->|Keep old code?| OTHER
FDON -->RF2
RF2-->|Keep old code?| RC

View File

@ -109,6 +109,11 @@ Even when creation is disabled, you can still use and revoke existing project ac
## Bot users for projects
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/462217) in GitLab 17.2 [with a flag](../../../administration/feature_flags.md) named `retain_resource_access_token_user_after_revoke`. Disabled by default. When enabled, the bot user is retained. It is not deleted and its records are not moved to the Ghost User.
FLAG:
The behavior of the bot user after the project access token is revoked is controlled by a feature flag. For more information, see the history.
Bot users for projects are [GitLab-created service accounts](../../../subscriptions/self_managed/index.md#billable-users).
Each time you create a project access token, a bot user is created and added to the project.
This user is not a billable user, so it does not count toward the license limit.

View File

@ -4467,9 +4467,6 @@ msgstr ""
msgid "AdminUsers|Skype"
msgstr ""
msgid "AdminUsers|Sort by"
msgstr ""
msgid "AdminUsers|Stop monitoring %{username} for possible spam?"
msgstr ""
@ -5514,6 +5511,9 @@ msgstr ""
msgid "An error occurred while checking group path. Please refresh and try again."
msgstr ""
msgid "An error occurred while creating the group. Please try again."
msgstr ""
msgid "An error occurred while creating the issue. Please try again."
msgstr ""
@ -12916,6 +12916,9 @@ msgstr ""
msgid "CodeownersValidation|Zero owners"
msgstr ""
msgid "Cohorts"
msgstr ""
msgid "Cohorts|Active users"
msgstr ""
@ -20305,6 +20308,9 @@ msgstr ""
msgid "Enter epic URL"
msgstr ""
msgid "Enter group name"
msgstr ""
msgid "Enter in your Bitbucket Server URL and personal access token below"
msgstr ""
@ -51740,10 +51746,13 @@ msgstr ""
msgid "SubscriptionBanner|Upload new license"
msgstr ""
msgid "SubscriptionGroupsNew|Group name %{error}"
msgstr ""
msgid "SubscriptionGroupsNew|Select a group for your %{planName} subscription"
msgstr ""
msgid "SubscriptionGroupsNew|Select a group for your subscription"
msgid "SubscriptionGroupsNew|Select a group for your subscription."
msgstr ""
msgid "SubscriptionGroupsNew|The group is a top-level group on a Free tier"
@ -51755,6 +51764,9 @@ msgstr ""
msgid "SubscriptionGroupsNew|You're assigned the Owner role of the group"
msgstr ""
msgid "SubscriptionGroupsNew|Your group will be created at:"
msgstr ""
msgid "SubscriptionGroupsNew|Your group will only be displayed in the list above if:"
msgstr ""

View File

@ -20,7 +20,7 @@ describe('TaskListItemActions component', () => {
document.body.appendChild(li);
wrapper = shallowMountExtended(TaskListItemActions, {
provide: { canUpdate: true, issuableType },
provide: { issuableType },
attachTo: document.querySelector('div'),
});
};
@ -32,8 +32,8 @@ describe('TaskListItemActions component', () => {
category: 'tertiary',
icon: 'ellipsis_v',
placement: 'bottom-end',
toggleText: TaskListItemActions.i18n.taskActions,
textSrOnly: true,
toggleText: 'Task actions',
});
});

View File

@ -109,6 +109,8 @@ describe('WorkItemLabels component', () => {
const findRegularLabel = () => findAllLabels().at(0);
const findLabelWithDescription = () => findAllLabels().at(2);
const findDropdownContentsCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findCreateLabelButton = () => wrapper.findByTestId('create-label');
const findManageLabelsButton = () => wrapper.findByTestId('manage-labels');
const showDropdown = () => {
findWorkItemSidebarDropdownWidget().vm.$emit('dropdownShown');
@ -422,71 +424,94 @@ describe('WorkItemLabels component', () => {
});
});
describe('creating project label', () => {
beforeEach(async () => {
createComponent();
describe('create/manage label buttons', () => {
describe('when project context', () => {
beforeEach(() => {
createComponent({ isGroup: false });
});
wrapper.findByTestId('create-project-label').vm.$emit('click');
await nextTick();
});
it('renders "Create project label" button', () => {
expect(findCreateLabelButton().text()).toBe('Create project label');
});
describe('when "Create project label" button is clicked', () => {
it('renders "Create label" dropdown', () => {
expect(findDisclosureDropdown().props()).toMatchObject({
block: true,
startOpened: true,
toggleText: 'No labels',
});
expect(findDropdownContentsCreateView().props()).toEqual({
attrWorkspacePath: 'test-project-path',
fullPath: 'test-project-path',
labelCreateType: 'project',
searchKey: '',
workspaceType: 'project',
});
it('renders "Manage project labels" link', () => {
expect(findManageLabelsButton().text()).toBe('Manage project labels');
expect(findManageLabelsButton().attributes('href')).toBe('test-project-path/labels');
});
});
describe('when "hideCreateView" event is emitted', () => {
it('hides dropdown', async () => {
expect(findDisclosureDropdown().exists()).toBe(true);
expect(findDropdownContentsCreateView().exists()).toBe(true);
describe('when group context', () => {
beforeEach(() => {
createComponent({ isGroup: true });
});
findDropdownContentsCreateView().vm.$emit('hideCreateView');
it('renders "Create group label" button', () => {
expect(findCreateLabelButton().text()).toBe('Create group label');
});
it('renders "Manage group labels" link', () => {
expect(findManageLabelsButton().text()).toBe('Manage group labels');
expect(findManageLabelsButton().attributes('href')).toBe('test-project-path/labels');
});
});
describe('creating project label', () => {
beforeEach(async () => {
createComponent();
findCreateLabelButton().vm.$emit('click');
await nextTick();
expect(findDisclosureDropdown().exists()).toBe(false);
expect(findDropdownContentsCreateView().exists()).toBe(false);
});
});
describe('when "labelCreated" event is emitted', () => {
it('updates "createdLabelId" value and hides dropdown', async () => {
expect(findWorkItemSidebarDropdownWidget().props('createdLabelId')).toBe(undefined);
expect(findDisclosureDropdown().exists()).toBe(true);
expect(findDropdownContentsCreateView().exists()).toBe(true);
findDropdownContentsCreateView().vm.$emit('labelCreated', {
id: 'gid://gitlab/Label/55',
name: 'New label',
describe('when "Create project label" button is clicked', () => {
it('renders "Create label" dropdown', () => {
expect(findDisclosureDropdown().props()).toMatchObject({
block: true,
startOpened: true,
toggleText: 'No labels',
});
expect(findDropdownContentsCreateView().props()).toEqual({
attrWorkspacePath: 'test-project-path',
fullPath: 'test-project-path',
labelCreateType: 'project',
searchKey: '',
workspaceType: 'project',
});
});
await nextTick();
});
expect(findWorkItemSidebarDropdownWidget().props('createdLabelId')).toBe(
'gid://gitlab/Label/55',
);
expect(findDisclosureDropdown().exists()).toBe(false);
expect(findDropdownContentsCreateView().exists()).toBe(false);
describe('when "hideCreateView" event is emitted', () => {
it('hides dropdown', async () => {
expect(findDisclosureDropdown().exists()).toBe(true);
expect(findDropdownContentsCreateView().exists()).toBe(true);
findDropdownContentsCreateView().vm.$emit('hideCreateView');
await nextTick();
expect(findDisclosureDropdown().exists()).toBe(false);
expect(findDropdownContentsCreateView().exists()).toBe(false);
});
});
describe('when "labelCreated" event is emitted', () => {
it('updates "createdLabelId" value and hides dropdown', async () => {
expect(findWorkItemSidebarDropdownWidget().props('createdLabelId')).toBe(undefined);
expect(findDisclosureDropdown().exists()).toBe(true);
expect(findDropdownContentsCreateView().exists()).toBe(true);
findDropdownContentsCreateView().vm.$emit('labelCreated', {
id: 'gid://gitlab/Label/55',
name: 'New label',
});
await nextTick();
expect(findWorkItemSidebarDropdownWidget().props('createdLabelId')).toBe(
'gid://gitlab/Label/55',
);
expect(findDisclosureDropdown().exists()).toBe(false);
expect(findDropdownContentsCreateView().exists()).toBe(false);
});
});
});
});
it('renders "Manage project labels" link in dropdown', () => {
createComponent();
expect(wrapper.findByTestId('manage-project-labels').text()).toBe('Manage project labels');
expect(wrapper.findByTestId('manage-project-labels').attributes('href')).toBe(
'test-project-path/labels',
);
});
});

View File

@ -26,7 +26,8 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do
expect(response).to have_gitlab_http_status(:ok)
end
it "returns only draft notes authored by the current user" do
it "returns only draft notes authored by the current user",
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/448707' do
get api(base_url, user)
draft_note_ids = json_response.pluck("id")

View File

@ -243,13 +243,49 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do
end
context "when the user has valid permissions" do
context "when retain bot user ff is disabled" do
before do
stub_feature_flags(retain_resource_access_token_user_after_revoke: false)
end
it "deletes the #{source_type} access token from the #{source_type}" do
delete_token
expect(response).to have_gitlab_http_status(:no_content)
expect(token.reload).to be_revoked
expect(
Users::GhostUserMigration.where(user: project_bot, initiator_user: user)
).to be_exists
end
context "when using #{source_type} access token to DELETE other #{source_type} access token" do
let_it_be(:other_project_bot) { create(:user, :project_bot) }
let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) }
let_it_be(:token_id) { other_token.id }
before do
resource.add_maintainer(other_project_bot)
end
it "deletes the #{source_type} access token from the #{source_type}" do
delete_token
expect(response).to have_gitlab_http_status(:no_content)
expect(token.reload).not_to be_revoked
expect(other_token.reload).to be_revoked
expect(
Users::GhostUserMigration.where(user: other_project_bot, initiator_user: user)
).to be_exists
end
end
end
it "deletes the #{source_type} access token from the #{source_type}" do
delete_token
expect(response).to have_gitlab_http_status(:no_content)
expect(
Users::GhostUserMigration.where(user: project_bot, initiator_user: user)
).to be_exists
expect(token.reload).to be_revoked
expect(User.exists?(project_bot.id)).to be_truthy
end
context "when using #{source_type} access token to DELETE other #{source_type} access token" do
@ -265,9 +301,9 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do
delete_token
expect(response).to have_gitlab_http_status(:no_content)
expect(
Users::GhostUserMigration.where(user: other_project_bot, initiator_user: user)
).to be_exists
expect(token.reload).not_to be_revoked
expect(other_token.reload).to be_revoked
expect(User.exists?(other_project_bot.id)).to be_truthy
end
end

View File

@ -15,26 +15,18 @@ RSpec.describe ResourceAccessTokens::RevokeService, feature_category: :system_ac
shared_examples 'revokes access token' do
it { expect(subject.success?).to be true }
it { expect(subject.message).to eq("Access token #{access_token.name} has been revoked and the bot user has been scheduled for deletion.") }
it { expect(subject.message).to eq("Access token #{access_token.name} has been revoked.") }
it 'calls delete user worker' do
expect(DeleteUserWorker).to receive(:perform_async).with(user.id, resource_bot.id, skip_authorization: true)
it 'does not call the delete user worker' do
expect(DeleteUserWorker).not_to receive(:perform_async)
subject
end
it 'removes membership of bot user' do
it 'bot user retains membership' do
subject
expect(resource.reload).not_to have_user(resource_bot)
end
it 'initiates user removal' do
subject
expect(
Users::GhostUserMigration.where(user: resource_bot, initiator_user: user)
).to be_exists
expect(resource.reload).to have_user(resource_bot)
end
it 'logs the event' do
@ -44,6 +36,34 @@ RSpec.describe ResourceAccessTokens::RevokeService, feature_category: :system_ac
expect(Gitlab::AppLogger).to have_received(:info).with("PROJECT ACCESS TOKEN REVOCATION: revoked_by: #{user.username}, project_id: #{resource.id}, token_user: #{resource_bot.name}, token_id: #{access_token.id}")
end
context 'with retain user feature flag disabled' do
before do
stub_feature_flags(retain_resource_access_token_user_after_revoke: false)
end
it { expect(subject.message).to eq("Access token #{access_token.name} has been revoked and the bot user has been scheduled for deletion.") }
it 'calls delete user worker' do
expect(DeleteUserWorker).to receive(:perform_async).with(user.id, resource_bot.id, skip_authorization: true)
subject
end
it 'removes membership of bot user' do
subject
expect(resource.reload).not_to have_user(resource_bot)
end
it 'initiates user removal' do
subject
expect(
Users::GhostUserMigration.where(user: resource_bot, initiator_user: user)
).to be_exists
end
end
end
shared_examples 'rollback revoke steps' do

View File

@ -159,27 +159,64 @@ RSpec.shared_examples 'POST resource access tokens available' do
end
RSpec.shared_examples 'PUT resource access tokens available' do
it 'calls delete user worker' do
expect(DeleteUserWorker).to receive(:perform_async).with(user.id, access_token_user.id, skip_authorization: true)
context "when retain bot user ff is disabled" do
before do
stub_feature_flags(retain_resource_access_token_user_after_revoke: false)
end
it 'revokes the token' do
subject
expect(resource_access_token.reload).to be_revoked
end
it 'calls delete user worker' do
expect(DeleteUserWorker).to receive(:perform_async).with(user.id, access_token_user.id, skip_authorization: true)
subject
end
it 'removes membership of bot user' do
subject
resource_bots = if resource.is_a?(Project)
resource.bots
elsif resource.is_a?(Group)
User.bots.id_in(resource.all_group_members.non_invite.pluck(:user_id))
end
expect(resource_bots).not_to include(access_token_user)
end
it 'creates GhostUserMigration records to handle migration in a worker' do
expect { subject }.to(
change { Users::GhostUserMigration.count }.from(0).to(1))
end
end
it 'revokes the token' do
subject
expect(resource_access_token.reload).to be_revoked
end
it 'does not call delete user worker' do
expect(DeleteUserWorker).not_to receive(:perform_async)
subject
end
it 'removes membership of bot user' do
it 'does not remove membership of the bot' do
subject
resource_bots = if resource.is_a?(Project)
resource.bots
elsif resource.is_a?(Group)
User.bots.id_in(resource.all_group_members.non_invite.pluck_primary_key)
User.bots.id_in(resource.all_group_members.non_invite.pluck(:user_id))
end
expect(resource_bots).not_to include(access_token_user)
expect(resource_bots).to include(access_token_user)
end
it 'creates GhostUserMigration records to handle migration in a worker' do
expect { subject }.to(
change { Users::GhostUserMigration.count }.from(0).to(1))
it 'does not create GhostUserMigration records to handle migration in a worker' do
expect { subject }.not_to change { Users::GhostUserMigration.count }
end
context 'when unsuccessful' do