Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot
2025-02-21 15:07:16 +00:00
parent 05b3a2ebd7
commit aafd8d0b36
67 changed files with 886 additions and 405 deletions

View File

@ -593,9 +593,6 @@ export default {
'ee/app/assets/javascripts/tracing/details/tracing_details.vue',
'ee/app/assets/javascripts/usage_quotas/code_suggestions/components/code_suggestions_info_card.vue',
'ee/app/assets/javascripts/usage_quotas/code_suggestions/components/search_and_sort_bar.vue',
'ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue',
'ee/app/assets/javascripts/usage_quotas/pipelines/components/minutes_usage_per_project_chart.vue',
'ee/app/assets/javascripts/usage_quotas/pipelines/components/shared_runner_usage_month_chart.vue',
'ee/app/assets/javascripts/usage_quotas/seats/components/statistics_seats_card.vue',
'ee/app/assets/javascripts/usage_quotas/transfer/components/usage_by_month.vue',
'ee/app/assets/javascripts/users/identity_verification/components/credit_card_verification.vue',

View File

@ -2,27 +2,6 @@
# Cop supports --autocorrect.
RSpec/BeNil:
Exclude:
- 'ee/spec/services/app_sec/dast/profiles/update_service_spec.rb'
- 'ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb'
- 'ee/spec/workers/concerns/geo/skip_secondary_spec.rb'
- 'ee/spec/workers/repository_update_mirror_worker_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/scan_execution_policy_vulnerabilities_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/scan_result_policy_license_finding_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/scan_result_policy_vulnerabilities_spec.rb'
- 'qa/spec/page/element_spec.rb'
- 'qa/spec/service/docker_run/mixins/third_party_docker_spec.rb'
- 'qa/spec/service/shellout_spec.rb'
- 'spec/config/object_store_settings_spec.rb'
- 'spec/controllers/application_controller_spec.rb'
- 'spec/dot_gitlab_ci/ci_configuration_validation/shared_context_and_examples.rb'
- 'spec/features/admin/users/admin_impersonates_user_spec.rb'
- 'spec/finders/container_repositories_finder_spec.rb'
- 'spec/finders/uploader_finder_spec.rb'
- 'spec/graphql/mutations/issues/set_due_date_spec.rb'
- 'spec/graphql/resolvers/container_repositories_resolver_spec.rb'
- 'spec/graphql/resolvers/paginated_tree_resolver_spec.rb'
- 'spec/graphql/resolvers/tree_resolver_spec.rb'
- 'spec/graphql/resolvers/users/group_count_resolver_spec.rb'
- 'spec/helpers/namespaces_helper_spec.rb'
- 'spec/helpers/tree_helper_spec.rb'
- 'spec/helpers/version_check_helper_spec.rb'

View File

@ -1 +1 @@
6af2d5f99e37feee2b7221af5f276040b8109195
7dbf8d6fbb832f81e2bfb0a4c143a0932cbecf53

View File

@ -1 +1 @@
93b9e36e23c2a4e51dc2012932830b72c8f838aa
fecc9b9bcb1ab8b69fb72be11705fd47925302d2

View File

@ -1,38 +1,43 @@
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { GlFilteredSearch, GlSorting } from '@gitlab/ui';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { TOKENS } from '../constants';
import { initializeValuesFromQuery } from '../utils';
import { TOKENS, SORT_OPTIONS } from '../constants';
import { initializeValuesFromQuery, buildSortedUrl } from '../utils';
export default {
components: {
GlFilteredSearch,
GlSorting,
},
data() {
return { tokens: initializeValuesFromQuery() };
const { tokens, sorting } = initializeValuesFromQuery();
return {
tokens,
sorting,
};
},
computed: {
availableTokens() {
// Once SSH or GPG key is selected, discard the rest of the tokens
if (this.hasKey()) {
if (this.hasKey) {
return TOKENS.filter(({ type }) => type === 'filter');
}
return TOKENS;
},
},
methods: {
change() {
// Once SSH or GPG key is selected, discard the rest of the tokens
if (this.hasKey()) {
this.tokens = this.tokens.filter(({ type }) => type === 'filter');
}
},
hasKey() {
return this.tokens.some(
({ type, value }) => type === 'filter' && ['ssh_keys', 'gpg_keys'].includes(value.data),
);
},
},
methods: {
change() {
// Once SSH or GPG key is selected, discard the rest of the tokens
if (this.hasKey) {
this.tokens = this.tokens.filter(({ type }) => type === 'filter');
}
},
search(tokens) {
const newParams = {};
@ -47,21 +52,39 @@ export default {
newParams[token.type] = token.value.data;
}
});
const newUrl = setUrlParams(newParams, window.location.href, true);
visitUrl(newUrl);
},
handleSortChange(value) {
visitUrl(buildSortedUrl(value, this.sorting.isAsc));
},
handleSortDirectionChange(isAsc) {
visitUrl(buildSortedUrl(this.sorting.value, isAsc));
},
},
SORT_OPTIONS,
};
</script>
<template>
<gl-filtered-search
v-model="tokens"
:placeholder="s__('CredentialsInventory|Search or filter credentials...')"
:available-tokens="availableTokens"
terms-as-tokens
@submit="search"
@input="change"
/>
<div class="gl-flex gl-flex-col gl-gap-3 md:gl-flex-row">
<gl-filtered-search
v-model="tokens"
:placeholder="s__('CredentialsInventory|Search or filter credentials...')"
:available-tokens="availableTokens"
terms-as-tokens
@submit="search"
@input="change"
/>
<gl-sorting
v-if="!hasKey"
block
dropdown-class="gl-w-full"
:is-ascending="sorting.isAsc"
:sort-by="sorting.value"
:sort-options="$options.SORT_OPTIONS"
@sortByChange="handleSortChange"
@sortDirectionChange="handleSortDirectionChange"
/>
</div>
</template>

View File

@ -6,6 +6,10 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import DateToken from '~/vue_shared/components/filtered_search_bar/tokens/date_token.vue';
export const SORT_KEY_NAME = 'name';
export const SORT_KEY_CREATED = 'created';
export const SORT_KEY_EXPIRES = 'expires';
export const TOKENS = [
{
icon: 'key',
@ -73,3 +77,34 @@ export const TOKENS = [
unique: true,
},
];
export const SORT_OPTIONS = [
{
text: __('Name'),
value: SORT_KEY_NAME,
sort: {
asc: 'name_asc',
desc: 'name_desc',
},
},
{
text: __('Created date'),
value: SORT_KEY_CREATED,
sort: {
asc: 'created_asc',
desc: 'created_desc',
},
},
{
text: __('Expiration date'),
value: SORT_KEY_EXPIRES,
sort: {
asc: 'expires_at_asc_id_desc',
},
},
];
export const DEFAULT_SORT = {
value: SORT_KEY_EXPIRES,
isAsc: true,
};

View File

@ -1,9 +1,9 @@
import Vue from 'vue';
import CredentialsFilterApp from './components/credentials_filter_app.vue';
import CredentialsFilterSortApp from './components/credentials_filter_sort_app.vue';
export const initCredentialsFilterApp = () => {
export const initCredentialsFilterSortApp = () => {
return new Vue({
el: document.querySelector('#js-credentials-filter-app'),
render: (createElement) => createElement(CredentialsFilterApp),
el: document.querySelector('#js-credentials-filter-sort-app'),
render: (createElement) => createElement(CredentialsFilterSortApp),
});
};

View File

@ -1,9 +1,9 @@
import { queryToObject } from '~/lib/utils/url_utility';
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
OPERATORS_BEFORE,
OPERATORS_AFTER,
} from '~/vue_shared/components/filtered_search_bar/constants';
import { TOKENS } from './constants';
import { TOKENS, SORT_OPTIONS, DEFAULT_SORT } from './constants';
/**
* @typedef {{type: string, value: {data: string, operator: string}}} Token
@ -16,9 +16,10 @@ import { TOKENS } from './constants';
* @returns {Array<string|Token>}
*/
export function initializeValuesFromQuery(query = document.location.search) {
const tokens = [];
const tokens = /** @type {Array<string|Token>} */ ([]);
const sorting = DEFAULT_SORT;
const { search, ...terms } = queryToObject(query);
const { search, sort, ...terms } = queryToObject(query);
for (const [key, value] of Object.entries(terms)) {
const isBefore = key.endsWith('_before');
@ -54,5 +55,18 @@ export function initializeValuesFromQuery(query = document.location.search) {
tokens.push(search);
}
return tokens;
const sortOption = SORT_OPTIONS.find((item) => [item.sort.desc, item.sort.asc].includes(sort));
if (sort && sortOption) {
sorting.value = sortOption.value;
sorting.isAsc = sortOption.sort.asc === sort;
}
return { tokens, sorting };
}
export function buildSortedUrl(value, isAsc) {
const sortedOption = SORT_OPTIONS.find((sortOption) => sortOption.value === value);
const sort = isAsc ? sortedOption.sort.asc : sortedOption.sort.desc;
const newUrl = setUrlParams({ sort });
return newUrl;
}

View File

@ -639,15 +639,12 @@ export default {
Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
Mousetrap.bind(keysFor(MR_NEXT_FILE_IN_DIFF), () => this.jumpToFile(+1));
if (this.commit) {
Mousetrap.bind(keysFor(MR_COMMITS_NEXT_COMMIT), () =>
this.moveToNeighboringCommit({ direction: 'next' }),
);
Mousetrap.bind(keysFor(MR_COMMITS_PREVIOUS_COMMIT), () =>
this.moveToNeighboringCommit({ direction: 'previous' }),
);
}
Mousetrap.bind(keysFor(MR_COMMITS_NEXT_COMMIT), () =>
this.moveToNeighboringCommit({ direction: 'next' }),
);
Mousetrap.bind(keysFor(MR_COMMITS_PREVIOUS_COMMIT), () =>
this.moveToNeighboringCommit({ direction: 'previous' }),
);
Mousetrap.bind(['mod+f', 'mod+g'], () => {
this.keydownTime = new Date().getTime();

View File

@ -1,5 +1,5 @@
import initConfirmModal from '~/confirm_modal';
import { initCredentialsFilterApp } from '~/credentials';
import { initCredentialsFilterSortApp } from '~/credentials';
initConfirmModal();
initCredentialsFilterApp();
initCredentialsFilterSortApp();

View File

@ -1,5 +1,5 @@
import initConfirmModal from '~/confirm_modal';
import { initCredentialsFilterApp } from '~/credentials';
import { initCredentialsFilterSortApp } from '~/credentials';
initConfirmModal();
initCredentialsFilterApp();
initCredentialsFilterSortApp();

View File

@ -0,0 +1,53 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapMutations } from 'vuex';
import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
import * as types from '~/diffs/store/mutation_types';
import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events';
export default {
name: 'FileBrowser',
components: {
DiffsFileTree,
},
props: {
loadedFiles: {
type: Object,
required: true,
},
},
data() {
return {
visible: true,
currentLoadedFiles: { ...this.loadedFiles },
};
},
created() {
document.addEventListener(DIFF_FILE_MOUNTED, this.addLoadedFile);
},
beforeDestroy() {
document.removeEventListener(DIFF_FILE_MOUNTED, this.addLoadedFile);
},
methods: {
...mapMutations('diffs', {
setCurrentDiffFile: types.SET_CURRENT_DIFF_FILE,
}),
addLoadedFile({ target }) {
this.currentLoadedFiles = { ...this.currentLoadedFiles, [target.id]: true };
},
clickFile(file) {
this.$emit('clickFile', file);
this.setCurrentDiffFile(file.fileHash);
},
},
};
</script>
<template>
<diffs-file-tree
:visible="visible"
:loaded-files="currentLoadedFiles"
@toggled="visible = !visible"
@clickFile="clickFile"
/>
</template>

View File

@ -3,7 +3,7 @@ import { initViewSettings } from '~/rapid_diffs/app/view_settings';
import { DiffFile } from '~/rapid_diffs/diff_file';
import { DiffFileMounted } from '~/rapid_diffs/diff_file_mounted';
import { useDiffsList } from '~/rapid_diffs/stores/diffs_list';
import { initFileBrowser } from '~/rapid_diffs/app/file_browser';
import { initFileBrowser } from '~/rapid_diffs/app/init_file_browser';
// This facade interface joins together all the bits and pieces of Rapid Diffs: DiffFile, Settings, File browser, etc.
// It's a unified entrypoint for Rapid Diffs and all external communications should happen through this interface.

View File

@ -1,6 +1,8 @@
import Vue from 'vue';
import store from '~/mr_notes/stores';
import DiffFileTree from '~/diffs/components/diffs_file_tree.vue';
import { pinia } from '~/pinia/instance';
import { DiffFile } from '~/rapid_diffs/diff_file';
import FileBrowser from './file_browser.vue';
export async function initFileBrowser() {
const el = document.querySelector('[data-file-browser]');
@ -9,21 +11,21 @@ export async function initFileBrowser() {
store.state.diffs.endpointMetadata = metadataEndpoint;
await store.dispatch('diffs/fetchDiffFilesMeta');
const loadedFiles = Object.fromEntries(DiffFile.getAll().map((file) => [file.id, true]));
// eslint-disable-next-line no-new
new Vue({
el,
data() {
return {
visible: true,
};
},
store,
pinia,
render(h) {
return h(DiffFileTree, {
props: { visible: this.visible },
return h(FileBrowser, {
props: {
loadedFiles,
},
on: {
toggled: () => {
this.visible = !this.visible;
clickFile(file) {
DiffFile.findByFileHash(file.fileHash).selectFile();
},
},
});

View File

@ -1,3 +1,4 @@
import { DIFF_FILE_MOUNTED } from './dom_events';
import { VIEWER_ADAPTERS } from './adapters';
// required for easier mocking in tests
import IntersectionObserver from './intersection_observer';
@ -24,11 +25,11 @@ export class DiffFile extends HTMLElement {
adapterConfig = VIEWER_ADAPTERS;
static findByFileHash(hash) {
return document.querySelector(`diff-file#${hash}`);
return document.querySelector(`diff-file[id="${hash}"]`);
}
static getAll() {
return document.querySelectorAll('diff-file');
return Array.from(document.querySelectorAll('diff-file'));
}
mount() {
@ -38,6 +39,7 @@ export class DiffFile extends HTMLElement {
this.observeVisibility();
this.diffElement.addEventListener('click', this.onClick.bind(this));
this.trigger(events.MOUNTED);
this.dispatchEvent(new CustomEvent(DIFF_FILE_MOUNTED, { bubbles: true }));
}
trigger(event, ...args) {
@ -73,6 +75,11 @@ export class DiffFile extends HTMLElement {
this.trigger(events.CLICK, event);
}
selectFile() {
this.scrollIntoView();
// TODO: add outline for active file
}
get data() {
const data = { ...this.dataset };
// viewer is dynamic, should be accessed via this.viewer

View File

@ -0,0 +1 @@
export const DIFF_FILE_MOUNTED = 'DiffFileMounted';

View File

@ -1,3 +1,10 @@
@import 'framework/variables';
.rd-diff-file-component {
// TODO: this must be defined using CSS Custom Properties to work across all pages
scroll-margin-top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height} + #{12px});
}
.rd-diff-file {
padding-bottom: $gl-padding;

View File

@ -1,6 +1,6 @@
-# TODO: add fork suggestion (commits only)
%diff-file{ id: id, data: server_data }
%diff-file.rd-diff-file-component{ id: id, data: server_data }
.rd-diff-file
= render RapidDiffs::DiffFileHeaderComponent.new(diff_file: @diff_file)
-# extra wrapper needed so content-visibility: hidden doesn't require removing border or other styles

View File

@ -10,7 +10,7 @@ module RapidDiffs
end
def id
@diff_file.file_identifier_hash
@diff_file.file_hash
end
def server_data

View File

@ -21,7 +21,7 @@ class ProcessCommitWorker
loggable_arguments 2, 3
deduplicate :until_executed, feature_flag: :deduplicate_process_commit_worker
concurrency_limit -> { 1000 if Feature.enabled?(:concurrency_limit_process_commit_worker, Feature.current_request) }
concurrency_limit -> { 1000 }
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.

View File

@ -1,9 +0,0 @@
---
name: concurrency_limit_process_commit_worker
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/472602
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/171786
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/502784
milestone: '17.6'
group: group::source code
type: worker
default_enabled: false

View File

@ -1,8 +1,9 @@
---
migration_job_name: BackfillProtectedEnvironmentApprovalRulesProtectedEnvironmentProjectId
description: Backfills sharding key `protected_environment_approval_rules.protected_environment_project_id` from `protected_environments`.
description: Backfills sharding key `protected_environment_approval_rules.protected_environment_project_id`
from `protected_environments`.
feature_category: continuous_delivery
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162704
milestone: '17.3'
queued_migration_version: 20240814104154
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20250220231747'

View File

@ -8,14 +8,6 @@ description: Notes created during the review of an MR that are not yet published
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4213
milestone: '11.4'
gitlab_schema: gitlab_main_cell
desired_sharding_key:
project_id:
references: projects
backfill_via:
parent:
foreign_key: merge_request_id
table: merge_requests
sharding_key: target_project_id
belongs_to: merge_request
desired_sharding_key_migration_job_name: BackfillDraftNotesProjectId
table_size: small
sharding_key:
project_id: projects

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddOrganizationIdToAiDuoChatEvents < Gitlab::Database::Migration[2.2]
milestone '17.10'
def change
add_column :ai_duo_chat_events, :organization_id, :bigint
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ValidateDraftNotesProjectIdNotNullConstraint < Gitlab::Database::Migration[2.2]
milestone '17.10'
def up
validate_not_null_constraint :draft_notes, :project_id
end
def down
# no-op
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class AddAiDuoChatEventsOrganizationIdIndex < Gitlab::Database::Migration[2.2]
include Gitlab::Database::PartitioningMigrationHelpers
disable_ddl_transaction!
milestone '17.10'
INDEX_NAME = 'index_ai_duo_chat_events_on_organization_id'
def up
add_concurrent_partitioned_index :ai_duo_chat_events, :organization_id, name: INDEX_NAME
end
def down
remove_concurrent_partitioned_index_by_name :ai_duo_chat_events, INDEX_NAME
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# This index was prepared in 17.9 PrepareNoteableIdNoteableTypeAndIdIndexInNotesTable migration
class AddNoteableIdNoteableTypeAndIdIndexInNotesTable < Gitlab::Database::Migration[2.2]
milestone '17.10'
disable_ddl_transaction!
INDEX_NAME = 'index_notes_on_noteable_id_noteable_type_and_id'
def up
# rubocop:disable Migration/PreventIndexCreation -- index prepared in advance
add_concurrent_index :notes, [:noteable_id, :noteable_type, :id], name: INDEX_NAME
# rubocop:enable Migration/PreventIndexCreation
end
def down
remove_concurrent_index_by_name :notes, INDEX_NAME
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FillAiDuoChatEventsOrganizationId < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
milestone '17.10'
def up
return unless Gitlab.ee? # Only EE has proper table partitions and data.
chat_events = define_batchable_model(:ai_duo_chat_events)
chat_events.each_batch(of: 1000, column: :id) do |batch|
batch.where(organization_id: nil).update_all(organization_id: 1) # DEFAULT_ORGANIZATION_ID
end
end
def down
# no-op
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class FinalizeHkBackfillProtectedEnvironmentApprovalRulesProtectedEnvironmen < Gitlab::Database::Migration[2.2]
milestone '17.10'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillProtectedEnvironmentApprovalRulesProtectedEnvironmentProjectId',
table_name: :protected_environment_approval_rules,
column_name: :id,
job_arguments: [:protected_environment_project_id, :protected_environments, :project_id,
:protected_environment_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
17898021bbd8b5bdef675c73e0aef30a77b0cbbeb6348b89e75f2b4837ec58b4

View File

@ -0,0 +1 @@
1d540f78bcd88abb82caedee131a8aa7cc73c1900784af93e797162e6f0edd51

View File

@ -0,0 +1 @@
5fc9dfad9645f9d0a955e37a7fe1702e572701226ec1bdbfba754e1455e52d1a

View File

@ -0,0 +1 @@
f6e2391f8d78b18c53b5c6e4e44fb93c59355badb4bc2aedbfdcb5f6f44b1548

View File

@ -0,0 +1 @@
7984c710d624f787cf69ba62ec525875ffcf7bada3fa763966125c21c5259043

View File

@ -0,0 +1 @@
f471852ecae0f6af89d72a75897d3c13cebe57e25fdb509c1816d219d6c11cd5

View File

@ -3973,6 +3973,7 @@ CREATE TABLE ai_duo_chat_events (
event smallint NOT NULL,
namespace_path text,
payload jsonb,
organization_id bigint,
CONSTRAINT check_628cdfbf3f CHECK ((char_length(namespace_path) <= 255))
)
PARTITION BY RANGE ("timestamp");
@ -13114,6 +13115,7 @@ CREATE TABLE draft_notes (
internal boolean DEFAULT false NOT NULL,
note_type smallint,
project_id bigint,
CONSTRAINT check_2a752d05fe CHECK ((project_id IS NOT NULL)),
CONSTRAINT check_c497a94a0e CHECK ((char_length(line_code) <= 255))
);
@ -27268,9 +27270,6 @@ ALTER TABLE ONLY chat_names
ALTER TABLE ONLY chat_teams
ADD CONSTRAINT chat_teams_pkey PRIMARY KEY (id);
ALTER TABLE draft_notes
ADD CONSTRAINT check_2a752d05fe CHECK ((project_id IS NOT NULL)) NOT VALID;
ALTER TABLE workspaces
ADD CONSTRAINT check_2a89035b04 CHECK ((personal_access_token_id IS NOT NULL)) NOT VALID;
@ -31511,6 +31510,8 @@ CREATE INDEX index_ai_conversation_threads_on_organization_id ON ai_conversation
CREATE INDEX index_ai_conversation_threads_on_user_id_and_last_updated_at ON ai_conversation_threads USING btree (user_id, last_updated_at);
CREATE INDEX index_ai_duo_chat_events_on_organization_id ON ONLY ai_duo_chat_events USING btree (organization_id);
CREATE INDEX index_ai_duo_chat_events_on_personal_namespace_id ON ONLY ai_duo_chat_events USING btree (personal_namespace_id);
CREATE INDEX index_ai_duo_chat_events_on_user_id ON ONLY ai_duo_chat_events USING btree (user_id);
@ -33959,6 +33960,8 @@ CREATE INDEX index_notes_on_namespace_id ON notes USING btree (namespace_id);
CREATE INDEX index_notes_on_noteable_id_and_noteable_type_and_system ON notes USING btree (noteable_id, noteable_type, system);
CREATE INDEX index_notes_on_noteable_id_noteable_type_and_id ON notes USING btree (noteable_id, noteable_type, id);
CREATE INDEX index_notes_on_project_id_and_id_and_system_false ON notes USING btree (project_id, id) WHERE (NOT system);
CREATE INDEX index_notes_on_project_id_and_noteable_type ON notes USING btree (project_id, noteable_type);

View File

@ -92,7 +92,7 @@ We provide two debugging scripts to help administrators verify their self-hosted
```
For a `mixtral` model running on vLLM:
```shell
poetry run troubleshoot \
--model-family=mixtral \
@ -114,6 +114,14 @@ Verify the output of the commands, and fix accordingly.
If both commands are successful, but GitLab Duo Code Suggestions is still not working,
raise an issue on the issue tracker.
## GitLab Duo health check is not working
When you [run a health check for GitLab Duo](../../user/gitlab_duo/setup.md#run-a-health-check-for-gitlab-duo), you might get an error like a `401 response from the AI gateway`.
To resolve, first check if GitLab Duo features are functioning correctly. For example, send a message to Duo Chat.
If this does not work, the error might be because of a known issue with GitLab Duo health check. For more information, see [issue 517097](https://gitlab.com/gitlab-org/gitlab/-/issues/517097).
## Check if GitLab can make a request to the model
From the GitLab Rails console, verify that GitLab can make a request to the model

View File

@ -112,3 +112,5 @@ These tests are performed:
| Network | Tests whether your instance can connect to `customers.gitlab.com` and `cloud.gitlab.com`.<br><br>If your instance cannot connect to either destination, ensure that your firewall or proxy server settings [allow connection](setup.md). |
| Synchronization | Tests whether your subscription: <br>- Has been activated with an activation code and can be synchronized with `customers.gitlab.com`.<br>- Has correct access credentials.<br>- Has been synchronized recently. If it hasn't or the access credentials are missing or expired, you can [manually synchronize](../../subscriptions/self_managed/_index.md#manually-synchronize-subscription-data) your subscription data. |
| System exchange | Tests whether Code Suggestions can be used in your instance. If the system exchange assessment fails, users might not be able to use GitLab Duo features. |
If you are experiencing any issues with the health check, see [GitLab Duo Self-Hosted troubleshooting](../../administration/gitlab_duo_self_hosted/troubleshooting.md#gitlab-duo-health-check-is-not-working).

View File

@ -5,347 +5,348 @@ info: To determine the technical writer assigned to the Stage/Group associated w
title: Publish packages with Yarn
---
You can publish packages with [Yarn 1 (Classic)](https://classic.yarnpkg.com) and [Yarn 2+](https://yarnpkg.com).
You can publish and install packages with [Yarn 1 (Classic)](https://classic.yarnpkg.com) and [Yarn 2+](https://yarnpkg.com).
To find the Yarn version used in the deployment container, run `yarn --version` in the `script` block of the CI
To find the Yarn version used in the deployment container, run `yarn --version` in the `script` block of the CI/CD
script job block that is responsible for calling `yarn publish`. The Yarn version is shown in the pipeline output.
Learn how to build a [Yarn](../workflows/build_packages.md#yarn) package.
## Authenticating to the package registry
You can use the Yarn documentation to get started with
[Yarn Classic](https://classic.yarnpkg.com/en/docs/getting-started) and
[Yarn 2+](https://yarnpkg.com/getting-started).
## Publish to GitLab package registry
You can use Yarn to publish to the GitLab package registry.
### Authentication to the package registry
You need a token to publish a package. Different tokens are available depending on what you're trying to
You need a token to interact with the package registry. Different tokens are available depending on what you're trying to
achieve. For more information, review the [guidance on tokens](../package_registry/_index.md#authenticate-with-the-registry).
- If your organization uses two-factor authentication (2FA), you must use a
personal access token with the scope set to `api`.
- If you publish a package via CI/CD pipelines, you can use a CI job token in
private runners or you can register a variable for instance runners.
[personal access token](../../profile/personal_access_tokens.md) with the scope set to `api`.
- If you publish a package with CI/CD pipelines, you can use a [CI/CD job token](../../../ci/jobs/ci_job_token.md) with
private runners. You can also [register a variable](https://docs.gitlab.com/runner/register/#register-with-a-runner-authentication-token) for instance runners.
### Publish configuration
### Configure Yarn for publication
To publish, set the following configuration in `.yarnrc.yml`. This file should be
located in the root directory of your package project source where `package.json` is found.
To configure Yarn to publish to the package registry, edit your `.yarnrc.yml` file.
You can find this file in root directory of your project, in the same place as the `package.json` file.
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: 'https://<your_domain>/api/v4/projects/<your_project_id>/packages/npm/'
npmAlwaysAuth: true
npmAuthToken: '<your_token>'
```
- Edit `.yarnrc.yml` and add the following configuration:
In this configuration:
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: 'https://<domain>/api/v4/projects/<project_id>/packages/npm/'
npmAlwaysAuth: true
npmAuthToken: '<token>'
```
- Replace `<my-org>` with your organization scope, excluding the `@` symbol.
- Replace `<your_domain>` with your domain name.
- Replace `<your_project_id>` with your project's ID, which you can find on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
- Replace `<your_token>` with a deployment token, group access token, project access token, or personal access token.
In this configuration:
Scoped registry does not work in Yarn Classic in `package.json` file, based on
this [issue](https://github.com/yarnpkg/yarn/pull/7829).
Therefore, under `publishConfig` there should be `registry` and not `@scope:registry` for Yarn Classic.
You can publish using your command line or a CI/CD pipeline to the GitLab package registry.
- Replace `<my-org>` with your organization scope. Do not include the `@` symbol.
- Replace `<domain>` with your domain name.
- Replace `<project_id>` with your project's ID, which you can find on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
- Replace `<token>` with a deployment token, group access token, project access token, or personal access token.
### Publishing via the command line - Manual Publish
In Yarn Classic, scoped registries with `publishConfig["@scope:registry"]` are not supported. See [Yarn pull request 7829](https://github.com/yarnpkg/yarn/pull/7829) for more information.
Instead, set `publishConfig` to `registry` in your `package.json` file.
```shell
# Yarn 1 (Classic)
yarn publish
## Publish a package
# Yarn 2+
yarn npm publish
```
You can publish a package from the command line, or with GitLab CI/CD.
Your package should now publish to the package registry.
### With the command line
### Publishing via a CI/CD pipeline - Automated Publish
To publish a package manually:
You can use pipeline variables when you use this method.
- Run the following command:
You can use **instance runners** *(Default)* or **Private Runners** (Advanced).
```shell
# Yarn 1 (Classic)
yarn publish
#### Instance runners
# Yarn 2+
yarn npm publish
```
To create an authentication token for your project or group:
### With CI/CD
1. On the left sidebar, select **Search or go to** and find your project or group.
1. On the left sidebar, select **Settings > Repository > Deploy Tokens**.
1. Create a deployment token with `read_package_registry` and `write_package_registry` scopes and copy the generated token.
1. On the left sidebar, select **Settings > CI/CD > Variables**.
1. Select `Add variable` and use the following settings:
You can publish a package automatically with instance runners (default) or private runners (advanced).
You can use pipeline variables when you publish with CI/CD.
| Field | Value |
|--------------------|------------------------------|
| key | `NPM_AUTH_TOKEN` |
| value | `<DEPLOY-TOKEN-FROM-STEP-3>` |
| type | Variable |
| Protected variable | `CHECKED` |
| Mask variable | `CHECKED` |
| Expand variable | `CHECKED` |
{{< tabs >}}
To use any **Protected variable**:
{{< tab title="Instance runners" >}}
1. Create an authentication token for your project or group:
1. On the left sidebar, select **Search or go to** and find your project or group.
1. On the left sidebar, select **Settings > Repository > Deploy Tokens**.
1. Create a deployment token with `read_package_registry` and `write_package_registry` scopes and copy the generated token.
1. On the left sidebar, select **Settings > CI/CD > Variables**.
1. Select `Add variable` and use the following settings:
| Field | Value |
|--------------------|------------------------------|
| key | `NPM_AUTH_TOKEN` |
| value | `<DEPLOY-TOKEN>` |
| type | Variable |
| Protected variable | `CHECKED` |
| Mask variable | `CHECKED` |
| Expand variable | `CHECKED` |
1. Optional. To use protected variables:
1. Go to the repository that contains the Yarn package source code.
1. On the left sidebar, select **Settings > Repository**.
- If you are building from branches with tags, select **Protected Tags** and add `v*` (wildcard) for semantic versioning.
- If you are building from branches without tags, select **Protected Branches**.
Then add the `NPM_AUTH_TOKEN` created above, to the `.yarnrc.yml` configuration
1. Add the `NPM_AUTH_TOKEN` you created to the `.yarnrc.yml` configuration
in your package project root directory where `package.json` is found:
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/"
npmAlwaysAuth: true
npmAuthToken: "${NPM_AUTH_TOKEN}"
```
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: '${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/'
npmAlwaysAuth: true
npmAuthToken: '${NPM_AUTH_TOKEN}'
```
In this configuration, replace `<my-org>` with your organization scope, excluding the `@` symbol.
In this configuration, replace `<my-org>` with your organization scope, excluding the `@` symbol.
#### Private runners
{{< /tab >}}
Add the `CI_JOB_TOKEN` to the `.yarnrc.yml` configuration in your package project
root directory where `package.json` is found:
{{< tab title="Private runners" >}}
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/"
npmAlwaysAuth: true
npmAuthToken: "${CI_JOB_TOKEN}"
```
1. Add your `CI_JOB_TOKEN` to the `.yarnrc.yml` configuration in the root directory of your package project, where `package.json` is located:
In this configuration, replace `<my-org>` with your organization scope, excluding the `@` symbol.
```yaml
npmScopes:
<my-org>:
npmPublishRegistry: '${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/'
npmAlwaysAuth: true
npmAuthToken: '${CI_JOB_TOKEN}'
```
To publish the package using CI/CD pipeline, In the GitLab project that houses
your `.yarnrc.yml`, edit or create a `.gitlab-ci.yml` file. For example to trigger
only on any tag push:
In this configuration, replace `<my-org>` with your organization scope, excluding the `@` symbol.
```yaml
# Yarn 1
image: node:lts
1. In the GitLab project with your `.yarnrc.yml`, edit or create a `.gitlab-ci.yml` file.
For example, to trigger only on any tag push:
stages:
- deploy
In Yarn 1:
```yaml
image: node:lts
rules:
- if: $CI_COMMIT_TAG
stages:
- deploy
deploy:
stage: deploy
script:
- yarn publish
```
rules:
- if: $CI_COMMIT_TAG
```yaml
# Yarn 2+
image: node:lts
deploy:
stage: deploy
script:
- yarn publish
```
stages:
- deploy
In Yarn 2 and higher:
rules:
- if: $CI_COMMIT_TAG
```yaml
image: node:lts
deploy:
stage: deploy
before_script:
- corepack enable
- yarn set version stable
script:
- yarn npm publish
```
stages:
- deploy
Your package should now publish to the package registry when the pipeline runs.
rules:
- if: $CI_COMMIT_TAG
deploy:
stage: deploy
before_script:
- corepack enable
- yarn set version stable
script:
- yarn npm publish
```
When the pipeline runs, your package is added to the package registry.
{{< /tab >}}
{{< /tabs >}}
## Install a package
{{< alert type="note" >}}
You can install from an instance or project. If multiple packages have the same name and version,
only the most recently published package is retrieved when you install a package.
If multiple packages have the same name and version, the most recently-published
package is retrieved when you install a package.
### Scoped package names
{{< /alert >}}
To install from an instance, a package must be named with a [scope](https://docs.npmjs.com/misc/scope/).
You can set up the scope for your package in the `.yarnrc.yml` file and with the `publishConfig` option in the `package.json`.
You don't need to follow package naming conventions if you install from a project or group.
You can use one of two API endpoints to install packages:
A package scope begins with a `@` and follows the format `@owner/package-name`:
- **Instance-level**: Best used when working with many packages in an organization scope.
- The `@owner` is the top-level project that hosts the packages, not the root of the project with the package source code.
- The package name can be anything.
- If you plan to install a package through the [instance level](#install-from-the-instance-level),
then you must name your package with a [scope](https://docs.npmjs.com/misc/scope/).
Scoped packages begin with a `@` and have the `@owner/package-name` format. You can set up
the scope for your package in the `.yarnrc.yml` file and by using the `publishConfig`
option in the `package.json`.
For example:
- The value used for the `@scope` is the organization root (top-level project) `...com/my-org`
*(@my-org)* that hosts the packages, not the root of the project with the package's source code.
- The scope is always lowercase.
- The package name can be anything you want `@my-org/any-name`.
- **Project-level**: For when you have a one-off package.
If you plan to install a package through the [project level](#install-from-the-project-level),
you do not have to adhere to the naming convention.
| Project URL | Package registry | Organization Scope | Full package name |
| Project URL | Package registry | Organization scope | Full package name |
|-------------------------------------------------------------------|----------------------|--------------------|-----------------------------|
| `https://gitlab.com/<my-org>/<group-name>/<package-name-example>` | Package Name Example | `@my-org` | `@my-org/package-name` |
| `https://gitlab.com/<example-org>/<group-name>/<project-name>` | Project Name | `@example-org` | `@example-org/project-name` |
You can install from the instance level or from the project level.
### Install from the instance
The configurations for `.yarnrc.yml` can be added per package consuming project
root where `package.json` is located, or you can use a global
configuration located in your system user home directory.
If you're working with many packages in the same organization scope, consider installing from the instance.
### Install from the instance level
1. Configure your organization scope. In your `.yarnrc.yml` file, add the following:
Use these steps for global configuration in the `.yarnrc.yml` file:
```yaml
npmScopes:
<my-org>:
npmRegistryServer: 'https://<domain_name>/api/v4/packages/npm'
```
1. [Configure organization scope](#configure-organization-scope).
1. [Set the registry](#set-the-registry).
- Replace `<my-org>` with the root level group of the project you're installing to the package from excluding the `@` symbol.
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
#### Configure organization scope
1. Optional. If your package is private, you must configure access to the package registry:
```yaml
npmScopes:
<my-org>:
npmRegistryServer: "https://<your_domain_name>/api/v4/packages/npm"
```
```yaml
npmRegistries:
//<domain_name>/api/v4/packages/npm:
npmAlwaysAuth: true
npmAuthToken: '<token>'
```
- Replace `<my-org>` with the root level group of the project you're installing to the package from excluding the `@` symbol.
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<token>` with a deployment token (recommended), group access token, project access token, or personal access token.
#### Set the registry
1. [Install the package with Yarn](#install-with-yarn).
Skip this step if your package is public not private.
### Install from a group or project
```yaml
npmRegistries:
//<your_domain_name>/api/v4/packages/npm:
npmAlwaysAuth: true
npmAuthToken: "<your_token>"
```
If you have a one-off package, you can install it from a group or project.
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<your_token>` with a deployment token (recommended), group access token, project access token, or personal access token.
{{< tabs >}}
### Install from the group level
{{< tab title="From a group" >}}
Use these steps for global configuration in the `.yarnrc.yml` file:
1. Configure the group scope. In your `.yarnrc.yml` file, add the following:
1. [Configure group scope](#configure-group-scope)
1. [Set the registry](#set-the-registry-group-level)
```yaml
npmScopes:
<my-org>:
npmRegistryServer: 'https://<domain_name>/api/v4/groups/<group_id>/-/packages/npm'
```
#### Configure group scope
- Replace `<my-org>` with the top-level group that contains the group you want to install from. Exclude the `@` symbol.
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<group_id>` with your group ID, found on the [group overview page](../../group#access-a-group-by-using-the-group-id).
```yaml
npmScopes:
<my-org>:
npmRegistryServer: "https://<your_domain_name>/api/v4/groups/<your_group_id>/-/packages/npm"
```
1. Optional. If your package is private, you must set the registry:
- Replace `<my-org>` with the root level group of the project you're installing to the package from excluding the `@` symbol.
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<your_group_id>` with your group ID, found on the [group overview page](../../group#access-a-group-by-using-the-group-id).
```yaml
npmRegistries:
//<domain_name>/api/v4/groups/<group_id>/-/packages/npm:
npmAlwaysAuth: true
npmAuthToken: "<token>"
```
#### Set the registry (group level)
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<token>` with a deployment token (recommended), group access token, project access token, or personal access token.
- Replace `<group_id>` with your group ID, found on the [group overview page](../../group#access-a-group-by-using-the-group-id).
```yaml
npmRegistries:
//<your_domain_name>/api/v4/groups/<your_group_id>/-/packages/npm:
npmAlwaysAuth: true
npmAuthToken: "<your_token>"
```
1. [Install the package with Yarn](#install-with-yarn).
- Replace `<my-org>` with the root level group of the project you're installing to the package from excluding the `@` symbol.
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<your_group_id>` with your group ID, found on the [group overview page](../../group#access-a-group-by-using-the-group-id).
{{< /tab >}}
### Install from the project level
{{< tab title="From a project" >}}
Use these steps for each project in the `.yarnrc.yml` file:
1. Configure the project scope. In your `.yarnrc.yml` file, add the following:
1. [Configure project scope](#configure-project-scope).
1. [Set the registry](#set-the-registry-project-level).
```yaml
npmScopes:
<my-org>:
npmRegistryServer: "https://<domain_name>/api/v4/projects/<project_id>/packages/npm"
```
#### Configure project scope
- Replace `<my-org>` with the top-level group that contains the project you want to install from. Exclude the `@` symbol.
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<project_id>` with your project ID, found on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
```yaml
npmScopes:
<my-org>:
npmRegistryServer: "https://<your_domain_name>/api/v4/projects/<your_project_id>/packages/npm"
```
1. Optional. If your package is private, you must set the registry:
- Replace `<my-org>` with the root level group of the project you're installing to the package from excluding the `@` symbol.
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<your_project_id>` with your project ID, found on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
```yaml
npmRegistries:
//<domain_name>/api/v4/projects/<project_id>/packages/npm:
npmAlwaysAuth: true
npmAuthToken: "<token>"
```
#### Set the registry (project level)
- Replace `<domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<token>` with a deployment token (recommended), group access token, project access token, or personal access token.
- Replace `<project_id>` with your project ID, found on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
Skip this step if your package is public not private.
1. [Install the package with Yarn](#install-with-yarn).
```yaml
npmRegistries:
//<your_domain_name>/api/v4/projects/<your_project_id>/packages/npm:
npmAlwaysAuth: true
npmAuthToken: "<your_token>"
```
{{< /tab >}}
- Replace `<your_domain_name>` with your domain name, for example, `gitlab.com`.
- Replace `<your_token>` with a deployment token (recommended), group access token, project access token, or personal access token.
- Replace `<your_project_id>` with your project ID, found on the [project overview page](../../project/working_with_projects.md#access-a-project-by-using-the-project-id).
{{< /tabs >}}
### Install the package
### Install with Yarn
For Yarn 2+, use `yarn add` either in the command line or in the CI/CD pipelines to install your packages:
{{< tabs >}}
{{< tab title="Yarn 2 or later" >}}
- Run `yarn add` either from the command line, or from a CI/CD pipeline:
```shell
yarn add @scope/my-package
```
#### For Yarn Classic
{{< /tab >}}
The Yarn Classic setup, requires both `.npmrc` and `.yarnrc` files as
[mentioned in issue](https://github.com/yarnpkg/yarn/issues/4451#issuecomment-753670295):
{{< tab title="Yarn Classic" >}}
- Place credentials in the `.npmrc` file.
- Place the scoped registry in the `.yarnrc` file.
Yarn Classic requires both a `.npmrc` and a `.yarnrc` file.
See [Yarn issue 4451](https://github.com/yarnpkg/yarn/issues/4451#issuecomment-753670295) for more information.
```shell
# .npmrc
## Instance level
//<your_domain_name>/api/v4/packages/npm/:_authToken="<your_token>"
## Group level
//<your_domain_name>/api/v4/groups/<your_group_id>/-/packages/npm/:_authToken="<your_token>"
## Project level
//<your_domain_name>/api/v4/projects/<your_project_id>/packages/npm/:_authToken="<your_token>"
1. Place your credentials in the `.npmrc` file, and the scoped registry in the `.yarnrc` file:
# .yarnrc
## Instance level
"@scope:registry" "https://<your_domain_name>/api/v4/packages/npm/"
## Group level
"@scope:registry" "https://<your_domain_name>/api/v4/groups/<your_group_id>/-/packages/npm/"
## Project level
"@scope:registry" "https://<your_domain_name>/api/v4/projects/<your_project_id>/packages/npm/"
```
```shell
# .npmrc
## For the instance
//<domain_name>/api/v4/packages/npm/:_authToken='<token>'
## For the group
//<domain_name>/api/v4/groups/<group_id>/-/packages/npm/:_authToken='<token>'
## For the project
//<domain_name>/api/v4/projects/<project_id>/packages/npm/:_authToken='<token>'
Then you can use `yarn add` to install your packages.
# .yarnrc
## For the instance
'@scope:registry' 'https://<domain_name>/api/v4/packages/npm/'
## For the group
'@scope:registry' 'https://<domain_name>/api/v4/groups/<group_id>/-/packages/npm/'
## For the project
'@scope:registry' 'https://<domain_name>/api/v4/projects/<project_id>/packages/npm/'
```
1. Run `yarn add` either from the command line, or from a CI/CD pipeline:
```shell
yarn add @scope/my-package
```
{{< /tab >}}
{{< /tabs >}}
## Related topics
- [npm documentation](../npm_registry/_index.md#helpful-hints)
- [npm package registry documentation](../npm_registry/_index.md#helpful-hints)
- [Yarn Migration Guide](https://yarnpkg.com/migration/guide)
- [Build a Yarn package](../workflows/build_packages.md#yarn)
## Troubleshooting
@ -365,11 +366,11 @@ info If you think this is a bug, please open a bug report with the information p
info Visit https://classic.yarnpkg.com/en/docs/cli/install for documentation about this command
```
In this case, the following commands creates a file called `.yarnrc` in the current directory. Make sure to be in either your user home directory for global configuration or your project root for per-project configuration:
In this case, the following commands create a file called `.yarnrc` in the current directory. Make sure to be in either your user home directory for global configuration or your project root for per-project configuration:
```shell
yarn config set '//gitlab.example.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken' "<your_token>"
yarn config set '//gitlab.example.com/api/v4/packages/npm/:_authToken' "<your_token>"
yarn config set '//gitlab.example.com/api/v4/projects/<project_id>/packages/npm/:_authToken' '<token>'
yarn config set '//gitlab.example.com/api/v4/packages/npm/:_authToken' '<token>'
```
### `yarn install` fails to clone repository as a dependency

View File

@ -9693,9 +9693,6 @@ msgstr ""
msgid "Billing|User successfully scheduled for removal. This process might take some time. Refresh the page to see the changes."
msgstr ""
msgid "Billing|User was successfully removed"
msgstr ""
msgid "Billing|You are about to remove user %{username} from your subscription. If you continue, the user will be removed from the %{namespace} group and all its subgroups and projects. This action can't be undone."
msgstr ""
@ -19742,6 +19739,9 @@ msgstr ""
msgid "Dependency list"
msgstr ""
msgid "DependencyListExport|License Identifiers"
msgstr ""
msgid "DependencyListExport|Location"
msgstr ""
@ -19751,9 +19751,18 @@ msgstr ""
msgid "DependencyListExport|Packager"
msgstr ""
msgid "DependencyListExport|Project"
msgstr ""
msgid "DependencyListExport|Version"
msgstr ""
msgid "DependencyListExport|Vulnerabilities Detected"
msgstr ""
msgid "DependencyListExport|Vulnerability IDs"
msgstr ""
msgid "DependencyProxy|%{docLinkStart}See the documentation%{docLinkEnd} for other ways to store Docker images in Dependency Proxy cache."
msgstr ""
@ -64399,7 +64408,7 @@ msgstr ""
msgid "Vulnerability|The CVSS (Common Vulnerability Scoring System) is a standardized framework for assessing and communicating the severity of security vulnerabilities in software. It provides a numerical score (ranging from 0.0 to 10.0) to indicate the severity risk of the vulnerability."
msgstr ""
msgid "Vulnerability|The Exploit Prediction Scoring System model produces a probability score between 0 and 1 indicating the likelihood that a vulnerability will be exploited in the next 30 days."
msgid "Vulnerability|The Exploit Prediction Scoring System model produces a percentage value between 0 and 100 that represents the likelihood that a vulnerability will be exploited in the next 30 days."
msgstr ""
msgid "Vulnerability|The scanner determined this vulnerability to be a false positive. Verify the evaluation before changing its status. %{linkStart}Learn more about false positive detection.%{linkEnd}"

View File

@ -115,7 +115,7 @@ module QA
def runner_auth_token
runner_list = shell("docker exec #{@name} sh -c 'gitlab-runner list'")
runner_list.match(/Token\e\[0;m=([a-zA-Z0-9_-]+)/i)&.[](1)
runner_list.match(/Token\e\[0;m=([^ ]+)/)&.[](1)
end
def unregister_command

View File

@ -53,7 +53,7 @@ RSpec.describe QA::Page::Element do
subject { described_class.new(:something) }
it 'has no attribute[pattern]' do
expect(subject.attributes[:pattern]).to be(nil)
expect(subject.attributes[:pattern]).to be_nil
end
it 'is not required by default' do

View File

@ -52,7 +52,7 @@ module QA
end
it 'resolving the registry returns nil' do
expect(service.third_party_registry).to be(nil)
expect(service.third_party_registry).to be_nil
end
it 'throws if environment is missing' do

View File

@ -40,7 +40,7 @@ module QA
expect(wait_thread).to receive(:value).twice.and_return(non_errored_wait)
subject.shell('docker login -u user -p secret', mask_secrets: %w[secret user]) do |output|
expect(output).not_to be(nil)
expect(output).not_to be_nil
expect(output).to eql('logged in as **** with password ****')
end
end

View File

@ -30,7 +30,6 @@ ee/spec/frontend/boards/components/epic_board_content_sidebar_spec.js
ee/spec/frontend/boards/components/epics_swimlanes_spec.js
ee/spec/frontend/ci/pipeline_details/header/pipeline_header_spec.js
ee/spec/frontend/ci/runner/components/runner_usage_spec.js
ee/spec/frontend/ci/secrets/components/secrets_app_spec.js
ee/spec/frontend/ci/secrets/components/secrets_breadcrumbs_spec.js
ee/spec/frontend/ci/secrets/router_spec.js
ee/spec/frontend/compliance_dashboard/components/frameworks_report/edit_framework/components/policies_section_spec.js

View File

@ -331,8 +331,8 @@ RSpec.describe ObjectStoreSettings, feature_category: :shared do
expect(settings['enabled']).to be false
expect(settings['direct_upload']).to be true
expect(settings['remote_directory']).to be nil
expect(settings['bucket_prefix']).to be nil
expect(settings['remote_directory']).to be_nil
expect(settings['bucket_prefix']).to be_nil
end
it 'respects original values' do
@ -346,7 +346,7 @@ RSpec.describe ObjectStoreSettings, feature_category: :shared do
expect(settings['enabled']).to be true
expect(settings['direct_upload']).to be true
expect(settings['remote_directory']).to eq 'artifacts'
expect(settings['bucket_prefix']).to be nil
expect(settings['bucket_prefix']).to be_nil
end
it 'supports bucket prefixes' do

View File

@ -775,7 +775,7 @@ RSpec.describe ApplicationController, feature_category: :shared do
it 'sets stream headers', :aggregate_failures do
subject
expect(response.headers['Content-Length']).to be nil
expect(response.headers['Content-Length']).to be_nil
expect(response.headers['X-Accel-Buffering']).to eq 'no'
expect(response.headers['Last-Modified']).to eq '0'
end

View File

@ -63,7 +63,7 @@ RSpec.describe 'Database schema',
abuse_reports: %w[reporter_id user_id],
abuse_report_notes: %w[discussion_id],
ai_code_suggestion_events: %w[user_id],
ai_duo_chat_events: %w[user_id],
ai_duo_chat_events: %w[user_id organization_id],
application_settings: %w[performance_bar_allowed_group_id slack_app_id snowplow_app_id eks_account_id
eks_access_key_id],
approvals: %w[user_id project_id],

View File

@ -104,7 +104,7 @@ end
RSpec.shared_examples 'default branch pipeline' do
it 'is valid' do
expect(pipeline.yaml_errors).to be nil
expect(pipeline.yaml_errors).to be_nil
expect(pipeline.errors).to be_empty
expect(pipeline.status).to eq('created')
expect(jobs).to include(expected_job_name)
@ -113,7 +113,7 @@ end
RSpec.shared_examples 'merge request pipeline' do
it "succeeds with expected job" do
expect(pipeline.yaml_errors).to be nil
expect(pipeline.yaml_errors).to be_nil
expect(pipeline.errors).to be_empty
expect(pipeline.status).to eq('created')
expect(jobs).to include(expected_job_name)
@ -124,7 +124,7 @@ RSpec.shared_examples 'merge train pipeline' do
let(:ci_merge_request_event_type) { 'merge_train' }
it "succeeds with expected job" do
expect(pipeline.yaml_errors).to be nil
expect(pipeline.yaml_errors).to be_nil
expect(pipeline.errors).to be_empty
expect(pipeline.status).to eq('created')
expect(jobs).to include('pre-merge-checks')

View File

@ -135,7 +135,7 @@ RSpec.describe 'Admin impersonates user', feature_category: :user_management do
subject
icon = first('[data-testid="incognito-icon"]')
expect(icon).not_to be nil
expect(icon).not_to be_nil
end
context 'when viewing the confirm email warning', :js do

View File

@ -102,7 +102,7 @@ RSpec.describe ContainerRepositoriesFinder do
project.project_feature.update!(container_registry_access_level: ProjectFeature::DISABLED)
end
it { is_expected.to be nil }
it { is_expected.to be_nil }
end
end
@ -119,13 +119,13 @@ RSpec.describe ContainerRepositoriesFinder do
context 'when subject_type is group' do
let(:subject_type) { group }
it { is_expected.to be nil }
it { is_expected.to be_nil }
end
context 'when subject_type is project' do
let(:subject_type) { project }
it { is_expected.to be nil }
it { is_expected.to be_nil }
end
end
end

View File

@ -38,7 +38,7 @@ RSpec.describe UploaderFinder, feature_category: :shared do
end
it 'returns nil' do
expect(subject).to be(nil)
expect(subject).to be_nil
end
end

View File

@ -1,8 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlFilteredSearch } from '@gitlab/ui';
import CredentialsFilterApp from '~/credentials/components/credentials_filter_app.vue';
import { GlFilteredSearch, GlSorting } from '@gitlab/ui';
import CredentialsFilterSortApp from '~/credentials/components/credentials_filter_sort_app.vue';
import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
import setWindowLocation from 'helpers/set_window_location_helper';
import { SORT_KEY_NAME } from '~/credentials/constants';
const mockFilters = [
'dummy',
@ -35,15 +37,28 @@ jest.mock('~/lib/utils/url_utility', () => {
};
});
describe('CredentialsFilterApp', () => {
describe('CredentialsFilterSortApp', () => {
let wrapper;
const URL_HOST = 'https://localhost/';
const createComponent = () => {
wrapper = shallowMount(CredentialsFilterApp);
wrapper = mount(CredentialsFilterSortApp, {
stubs: {
GlFilteredSearch: true,
},
});
};
beforeEach(() => {
setWindowLocation(URL_HOST);
});
const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findAvailableTokens = () => findFilteredSearch().props('availableTokens');
const findSortingComponent = () => wrapper.findComponent(GlSorting);
const findSortDirectionToggle = () =>
findSortingComponent().find('button[title^="Sort direction"]');
const findDropdownToggle = () => findSortingComponent().find('button[aria-haspopup="listbox"]');
describe('Mounts GlFilteredSearch with corresponding filters', () => {
it.each`
@ -126,4 +141,92 @@ describe('CredentialsFilterApp', () => {
);
});
});
describe('renders CredentialsSortApp component', () => {
it('when url has filter param with value personal_access_tokens', async () => {
setWindowLocation('?filter=personal_access_tokens');
createComponent();
await nextTick();
expect(findSortingComponent().exists()).toBe(true);
});
it('when url has no filter param', async () => {
createComponent();
await nextTick();
expect(findSortingComponent().exists()).toBe(true);
});
});
describe('sort dropdown', () => {
it('defaults to sorting by "Created date" in ascending order', async () => {
createComponent();
await nextTick();
expect(findSortingComponent().props('isAscending')).toBe(true);
expect(findDropdownToggle().text()).toBe('Expiration date');
});
it('sets the sort label correctly', () => {
setWindowLocation('?sort=name_asc');
createComponent();
expect(findDropdownToggle().text()).toBe('Name');
});
describe('new sort option is selected', () => {
beforeEach(async () => {
visitUrl.mockImplementation(() => {});
createComponent();
findSortingComponent().vm.$emit('sortByChange', SORT_KEY_NAME);
await nextTick();
});
it('sorts by new option', () => {
expect(visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=name_asc`);
});
});
});
describe('sort direction toggle', () => {
beforeEach(() => {
visitUrl.mockImplementation(() => {});
});
describe('when current sort direction is ascending', () => {
beforeEach(() => {
setWindowLocation('?sort=name_asc');
createComponent();
});
describe('when sort direction toggle is clicked', () => {
beforeEach(() => {
findSortDirectionToggle().trigger('click');
});
it('sorts in descending order', () => {
expect(visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=name_desc`);
});
});
});
describe('when current sort direction is descending', () => {
beforeEach(() => {
setWindowLocation('?sort=name_desc');
createComponent();
});
describe('when sort direction toggle is clicked', () => {
beforeEach(() => {
findSortDirectionToggle().trigger('click');
});
it('sorts in ascending order', () => {
expect(visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=name_asc`);
});
});
});
});
});

View File

@ -0,0 +1,11 @@
import { buildSortedUrl } from '~/credentials/utils';
describe('buildSortedUrl', () => {
it('builds correct URL for ascending sort', () => {
expect(buildSortedUrl('name', false)).toBe('http://test.host/?sort=name_desc');
});
it('builds correct URL for descending sort', () => {
expect(buildSortedUrl('created', true)).toBe('http://test.host/?sort=created_asc');
});
});

View File

@ -6,10 +6,10 @@ import { DiffFile } from '~/rapid_diffs/diff_file';
import { DiffFileMounted } from '~/rapid_diffs/diff_file_mounted';
import { useDiffsList } from '~/rapid_diffs/stores/diffs_list';
import { pinia } from '~/pinia/instance';
import { initFileBrowser } from '~/rapid_diffs/app/file_browser';
import { initFileBrowser } from '~/rapid_diffs/app/init_file_browser';
jest.mock('~/rapid_diffs/app/view_settings');
jest.mock('~/rapid_diffs/app/file_browser');
jest.mock('~/rapid_diffs/app/init_file_browser');
describe('Rapid Diffs App', () => {
let app;

View File

@ -0,0 +1,53 @@
import { shallowMount } from '@vue/test-utils';
import FileBrowser from '~/rapid_diffs/app/file_browser.vue';
import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue';
import store from '~/mr_notes/stores';
import * as types from '~/diffs/store/mutation_types';
describe('FileBrowser', () => {
let wrapper;
let commit;
const createComponent = ({ loadedFiles = {}, ...rest } = {}) => {
wrapper = shallowMount(FileBrowser, {
store,
propsData: {
loadedFiles,
...rest,
},
});
};
beforeEach(() => {
commit = jest.spyOn(store, 'commit');
});
it('passes down loaded files', () => {
const loadedFiles = { foo: 1 };
createComponent({ loadedFiles });
expect(wrapper.findComponent(DiffsFileTree).props('loadedFiles')).toStrictEqual(loadedFiles);
});
it('is visible by default', () => {
createComponent();
expect(wrapper.findComponent(DiffsFileTree).props('visible')).toBe(true);
});
it('toggles visibility', async () => {
createComponent();
await wrapper.findComponent(DiffsFileTree).vm.$emit('toggled');
expect(wrapper.findComponent(DiffsFileTree).props('visible')).toBe(false);
});
it('handles click', async () => {
const file = { fileHash: 'foo' };
createComponent();
await wrapper.findComponent(DiffsFileTree).vm.$emit('clickFile', file);
expect(wrapper.emitted('clickFile')).toStrictEqual([[file]]);
expect(commit).toHaveBeenCalledWith(
`diffs/${types.SET_CURRENT_DIFF_FILE}`,
file.fileHash,
undefined,
);
});
});

View File

@ -0,0 +1,75 @@
import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
import store from '~/mr_notes/stores';
import { initFileBrowser } from '~/rapid_diffs/app/init_file_browser';
import createEventHub from '~/helpers/event_hub_factory';
import waitForPromises from 'helpers/wait_for_promises';
import { DiffFile } from '~/rapid_diffs/diff_file';
jest.mock('~/rapid_diffs/app/file_browser.vue', () => ({
props: jest.requireActual('~/rapid_diffs/app/file_browser.vue').default.props,
render(h) {
return h('div', {
attrs: {
'data-file-browser-component': true,
'data-loaded-files': JSON.stringify(this.loadedFiles),
},
on: {
click: () => {
this.$emit('clickFile', { fileHash: 'first' });
},
},
});
},
}));
describe('Init file browser', () => {
let dispatch;
const getMountElement = () => document.querySelector('[data-file-browser]');
const getFileBrowser = () => document.querySelector('[data-file-browser-component]');
beforeEach(() => {
dispatch = jest.spyOn(store, 'dispatch').mockResolvedValue();
window.mrTabs = { eventHub: createEventHub() };
setHTMLFixture(
`
<div data-file-browser data-metadata-endpoint="/metadata"></div>
<diff-file id="first"></diff-file>
`,
);
});
beforeAll(() => {
customElements.define('diff-file', DiffFile);
});
afterEach(() => {
resetHTMLFixture();
});
it('sets metadata endpoint', () => {
initFileBrowser();
expect(store.state.diffs.endpointMetadata).toBe(getMountElement().dataset.metadataEndpoint);
});
it('fetches metadata', () => {
initFileBrowser();
expect(dispatch).toHaveBeenCalledWith('diffs/fetchDiffFilesMeta');
});
it('provides already loaded files', async () => {
initFileBrowser();
await waitForPromises();
expect(JSON.parse(getFileBrowser().dataset.loadedFiles)).toStrictEqual({ first: true });
});
it('handles file clicks', async () => {
const selectFile = jest.fn();
const spy = jest.spyOn(DiffFile, 'findByFileHash').mockReturnValue({ selectFile });
initFileBrowser();
await waitForPromises();
getFileBrowser().click();
expect(spy).toHaveBeenCalledWith('first');
expect(selectFile).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,6 @@
import { DiffFile } from '~/rapid_diffs/diff_file';
import IS from '~/rapid_diffs/intersection_observer';
import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events';
// We have to use var here because jest hoists mock calls, so let would be uninitialized at this point
// eslint-disable-next-line no-var
@ -61,19 +62,36 @@ describe('DiffFile Web Component', () => {
invisible: jest.fn(),
mounted: jest.fn(),
});
getWebComponentElement().mount();
});
it('observes diff element', () => {
getWebComponentElement().mount();
expect(IS.prototype.observe).toHaveBeenCalledWith(getWebComponentElement());
});
it('triggers mounted event', () => {
let emitted = false;
document.addEventListener(DIFF_FILE_MOUNTED, () => {
emitted = true;
});
getWebComponentElement().mount();
expect(adapter.mounted).toHaveBeenCalled();
expect(adapter.mounted.mock.instances[0]).toStrictEqual(getContext());
expect(emitted).toBe(true);
});
it('#selectFile', () => {
getWebComponentElement().mount();
const spy = jest.spyOn(getWebComponentElement(), 'scrollIntoView');
getWebComponentElement().selectFile();
expect(spy).toHaveBeenCalled();
});
describe('when visible', () => {
beforeEach(() => {
getWebComponentElement().mount();
});
it('handles all clicks', () => {
triggerVisibility(true);
getDiffElement().click();
@ -102,11 +120,11 @@ describe('DiffFile Web Component', () => {
});
describe('static methods', () => {
it('findByFileHash', () => {
it('#findByFileHash', () => {
expect(DiffFile.findByFileHash('fileHash')).toBeInstanceOf(DiffFile);
});
it('getAll', () => {
it('#getAll', () => {
document.body.innerHTML = `<diff-file></diff-file><diff-file></diff-file>`;
const instances = DiffFile.getAll();
expect(instances.length).toBe(2);

View File

@ -36,7 +36,7 @@ RSpec.describe Mutations::Issues::SetDueDate, feature_category: :api do
let(:due_date) { nil }
it 'updates due date to be nil' do
expect(mutated_issue.due_date).to be nil
expect(mutated_issue.due_date).to be_nil
end
end
@ -44,7 +44,7 @@ RSpec.describe Mutations::Issues::SetDueDate, feature_category: :api do
let(:due_date) { 'test' }
it 'updates due date to be nil' do
expect(mutated_issue.due_date).to be nil
expect(mutated_issue.due_date).to be_nil
end
end
end

View File

@ -88,7 +88,7 @@ RSpec.describe Resolvers::ContainerRepositoriesResolver do
end
context 'with unauthorized user' do
it { is_expected.to be nil }
it { is_expected.to be_nil }
end
end
end

View File

@ -57,7 +57,7 @@ RSpec.describe Resolvers::PaginatedTreeResolver, feature_category: :source_code_
end
it 'returns nil' do
is_expected.to be(nil)
is_expected.to be_nil
end
end
@ -67,7 +67,7 @@ RSpec.describe Resolvers::PaginatedTreeResolver, feature_category: :source_code_
end
it 'returns nil' do
is_expected.to be(nil)
is_expected.to be_nil
end
end

View File

@ -30,7 +30,7 @@ RSpec.describe Resolvers::TreeResolver do
result = resolve_repository({ ref: "master" })
expect(result).to be(nil)
expect(result).to be_nil
end
end
end

View File

@ -42,7 +42,7 @@ RSpec.describe Resolvers::Users::GroupCountResolver do
it do
result = batch_sync { resolve_group_count(user1, user2) }
expect(result).to be nil
expect(result).to be_nil
end
end
@ -50,7 +50,7 @@ RSpec.describe Resolvers::Users::GroupCountResolver do
it do
result = batch_sync { resolve_group_count(user1, nil) }
expect(result).to be nil
expect(result).to be_nil
end
end
end

View File

@ -186,6 +186,7 @@ RSpec.describe 'new tables missing sharding_key', feature_category: :cell do
WHERE c.column_name = 'organization_id'
AND (fk.referenced_table_name = 'organizations' OR fk.referenced_table_name IS NULL)
AND (c.column_default IS NOT NULL OR c.is_nullable::boolean OR fk.name IS NULL OR NOT fk.is_valid)
AND (c.table_schema = 'public')
ORDER BY c.table_name;
SQL
@ -202,7 +203,8 @@ RSpec.describe 'new tables missing sharding_key', feature_category: :cell do
"oauth_openid_requests" => "https://gitlab.com/gitlab-org/gitlab/-/issues/496717",
"oauth_device_grants" => "https://gitlab.com/gitlab-org/gitlab/-/issues/496717",
"uploads" => "https://gitlab.com/gitlab-org/gitlab/-/issues/398199",
"bulk_import_trackers" => "https://gitlab.com/gitlab-org/gitlab/-/issues/517823"
"bulk_import_trackers" => "https://gitlab.com/gitlab-org/gitlab/-/issues/517823",
"ai_duo_chat_events" => "https://gitlab.com/gitlab-org/gitlab/-/issues/516140"
}
has_lfk = ->(lfks) { lfks.any? { |k| k.options[:column] == 'organization_id' && k.to_table == 'organizations' } }

View File

@ -81,8 +81,8 @@ RSpec.describe 'Merge Requests Diffs stream', feature_category: :code_review_wor
it 'streams diffs except the offset' do
go(offset: offset)
offset_file_identifier_hashes = diff_files.to_a.take(offset).map(&:file_identifier_hash)
remaining_file_identifier_hashes = diff_files.to_a.slice(offset..).map(&:file_identifier_hash)
offset_file_identifier_hashes = diff_files.to_a.take(offset).map(&:file_hash)
remaining_file_identifier_hashes = diff_files.to_a.slice(offset..).map(&:file_hash)
expect(response).to have_gitlab_http_status(:success)
expect(response.body).not_to include(*offset_file_identifier_hashes)

View File

@ -7,7 +7,7 @@ RSpec.shared_examples 'with diffs_blobs param' do
go(diff_blobs: true)
expect(response).to have_gitlab_http_status(:success)
expect(response.body).to include(*diff_files.to_a.map(&:file_identifier_hash))
expect(response.body).to include(*diff_files.to_a.map(&:file_hash))
end
end
@ -17,8 +17,8 @@ RSpec.shared_examples 'with diffs_blobs param' do
it 'streams diffs except the offset' do
go(diff_blobs: true, offset: offset)
offset_file_identifier_hashes = diff_files.to_a.take(offset).map(&:file_identifier_hash)
remaining_file_identifier_hashes = diff_files.to_a.slice(offset..).map(&:file_identifier_hash)
offset_file_identifier_hashes = diff_files.to_a.take(offset).map(&:file_hash)
remaining_file_identifier_hashes = diff_files.to_a.slice(offset..).map(&:file_hash)
expect(response).to have_gitlab_http_status(:success)
expect(response.body).not_to include(*offset_file_identifier_hashes)
@ -28,6 +28,6 @@ RSpec.shared_examples 'with diffs_blobs param' do
end
def file_identifier_hashes(diff)
diff.diffs.diff_files.to_a.map(&:file_identifier_hash)
diff.diffs.diff_files.to_a.map(&:file_hash)
end
end

View File

@ -22,16 +22,6 @@ RSpec.describe ProcessCommitWorker, feature_category: :source_code_management do
expect(::Gitlab::SidekiqMiddleware::ConcurrencyLimit::WorkersMap.limit_for(worker: described_class)).to eq(1000)
end
context 'when concurrency_limit_process_commit_worker is disabled' do
before do
stub_feature_flags(concurrency_limit_process_commit_worker: false)
end
it 'does not have a concurrency limit' do
expect(::Gitlab::SidekiqMiddleware::ConcurrencyLimit::WorkersMap.limit_for(worker: described_class)).to eq(0)
end
end
describe '#perform' do
subject(:perform) { worker.perform(project_id, user_id, commit.to_hash, default) }