Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-04-15 21:11:53 +00:00
parent 36170967f8
commit 44475bb86a
21 changed files with 892 additions and 68 deletions

View File

@ -124,6 +124,19 @@ export const getPreferredLocales = () => {
const createDateTimeFormat = (formatOptions) =>
Intl.DateTimeFormat(getPreferredLocales(), formatOptions);
/**
* Creates an instance of Intl.ListFormat for the current locale.
*
* For example, for a list `['Motorcycle', 'Bus', 'Car']`:
* - This returns `Motorcycle, Bus, and Car` for locale `en`
* - This returns `Motorcycle, Bus oder Car` for locale `de`
*
* @param {Intl.ListFormatOptions} [formatOptions] - for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ListFormat
* @returns {Intl.ListFormat}
*/
const createListFormat = (formatOptions) =>
new Intl.ListFormat(getPreferredLocales(), formatOptions);
/**
* Formats a number as a string using `toLocaleString`.
* @param {number} value - number to be converted
@ -149,5 +162,6 @@ export { getPluralFormIndex };
export { pgettext as s__ };
export { sprintf };
export { createDateTimeFormat };
export { createListFormat };
export { formatNumber };
export default locale;

View File

@ -11,7 +11,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import getOpenMrCountForBlobPath from '~/repository/queries/open_mr_count.query.graphql';
import getOpenMrsForBlobPath from '~/repository/queries/open_mrs.query.graphql';
import { nDaysBefore } from '~/lib/utils/datetime/date_calculation_utility';
import { toYmd } from '~/analytics/shared/utils';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import { logError } from '~/lib/logger';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import MergeRequestListItem from './merge_request_list_item.vue';
@ -56,7 +56,7 @@ export default {
},
createdAfter() {
const lookbackDate = nDaysBefore(new Date(), OPEN_MR_AGE_LIMIT_DAYS - 1, { utc: true });
return toYmd(lookbackDate);
return formatDate(lookbackDate, 'yyyy-mm-dd HH:MM:ss Z', true);
},
isLoading() {
return this.$apollo.queries.loading;

View File

@ -0,0 +1,213 @@
<script>
import { GlButton, GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui';
import { debounce, intersectionBy, unionBy } from 'lodash';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, createListFormat, s__, sprintf } from '~/locale';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql';
import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
import { findLabelsWidget, formatLabelForListbox } from '../../utils';
export default {
components: {
GlButton,
GlCollapsibleListbox,
GlFormGroup,
},
inject: ['labelsManagePath'],
props: {
checkedItems: {
type: Array,
required: false,
default: () => [],
},
formLabel: {
type: String,
required: true,
},
formLabelId: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
isGroup: {
type: Boolean,
required: false,
default: false,
},
selectedLabelsIds: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
labelsCache: [],
searchLabels: [],
searchStarted: false,
searchTerm: '',
selectedIds: this.selectedLabelsIds ?? [],
};
},
apollo: {
searchLabels: {
query() {
return this.isGroup ? groupLabelsQuery : projectLabelsQuery;
},
variables() {
return {
fullPath: this.fullPath,
searchTerm: this.searchTerm,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
return data.workspace?.labels?.nodes ?? [];
},
error(error) {
this.$emit(
'error',
s__('WorkItem|Something went wrong when fetching labels. Please try again.'),
);
Sentry.captureException(error);
},
},
},
computed: {
checkedItemsLabels() {
const labels = this.checkedItems.flatMap((item) => findLabelsWidget(item)?.labels.nodes);
return intersectionBy(labels, 'id');
},
isLoading() {
return this.$apollo.queries.searchLabels.loading;
},
listboxItems() {
const allLabels = this.checkedItemsLabels.length
? this.checkedItemsLabels.filter((label) =>
label.title.toLowerCase().includes(this.searchTerm.toLowerCase()),
)
: this.searchLabels;
return this.selectedLabels.length
? [
{
text: __('Selected'),
options: this.selectedLabels.map(formatLabelForListbox),
},
{
text: __('All'),
textSrOnly: true,
options: allLabels.map(formatLabelForListbox),
},
]
: allLabels.map(formatLabelForListbox);
},
manageLabelText() {
return this.isGroup ? __('Manage group labels') : __('Manage project labels');
},
selectedLabels() {
return this.labelsCache.filter((label) => this.selectedIds.includes(label.id));
},
toggleText() {
if (!this.selectedLabels.length) {
return __('Select labels');
}
const selectedLabelTitles = this.selectedLabels.map((label) => label.title);
return selectedLabelTitles.length > 3
? sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
labelsString: selectedLabelTitles.at(0),
remainingLabelCount: selectedLabelTitles.length - 1,
})
: createListFormat().format(selectedLabelTitles);
},
},
watch: {
checkedItemsLabels(checkedItemsLabels) {
this.updateLabelsCache(checkedItemsLabels);
},
searchLabels(searchLabels) {
this.updateLabelsCache(searchLabels);
},
selectedLabelsIds(selectedLabelsIds) {
this.selectedIds = selectedLabelsIds;
},
},
created() {
this.setSearchTermDebounced = debounce(this.setSearchTerm, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
clearSearch() {
this.searchTerm = '';
this.$refs.listbox.$refs.searchBox.clearInput?.();
},
handleSelect(items) {
this.selectedIds = items;
this.clearSearch();
this.$emit('select', this.selectedIds);
},
handleShown() {
this.searchTerm = '';
this.searchStarted = true;
},
setSearchTerm(searchTerm) {
this.searchTerm = searchTerm;
},
updateLabelsCache(labels) {
// Need to store all labels we encounter so we can show "Selected" labels
// even if they're not found in the apollo `searchLabels` list
this.labelsCache = unionBy(this.labelsCache, labels, 'id');
},
},
};
</script>
<template>
<gl-form-group :label="formLabel" :label-for="formLabelId">
<gl-collapsible-listbox
ref="listbox"
block
is-check-centered
:items="listboxItems"
multiple
:no-results-text="s__('WorkItem|No matching results')"
searchable
:searching="isLoading"
:selected="selectedIds"
:toggle-id="formLabelId"
:toggle-text="toggleText"
@search="setSearchTermDebounced"
@select="handleSelect"
@shown="handleShown"
>
<template #list-item="{ item }">
<div class="gl-flex gl-items-center gl-gap-3 gl-break-anywhere">
<span
:style="{ background: item.color }"
class="gl-border gl-h-3 gl-w-5 gl-shrink-0 gl-rounded-base gl-border-white"
></span>
{{ item.text }}
</div>
</template>
<template #footer>
<div class="gl-border-t-1 gl-border-t-dropdown !gl-p-2 gl-border-t-solid">
<gl-button
class="!gl-mt-2 !gl-justify-start"
block
category="tertiary"
:href="labelsManagePath"
>
{{ manageLabelText }}
</gl-button>
</div>
</template>
</gl-collapsible-listbox>
</gl-form-group>
</template>

View File

@ -0,0 +1,65 @@
<script>
import { GlForm } from '@gitlab/ui';
import WorkItemBulkEditLabels from './work_item_bulk_edit_labels.vue';
export default {
components: {
GlForm,
WorkItemBulkEditLabels,
},
props: {
checkedItems: {
type: Array,
required: false,
default: () => [],
},
fullPath: {
type: String,
required: true,
},
isGroup: {
type: Boolean,
required: true,
},
},
data() {
return {
addLabelIds: [],
removeLabelIds: [],
};
},
methods: {
handleFormSubmitted() {
this.$emit('bulk-update', {
ids: this.checkedItems.map((item) => item.id),
addLabelIds: this.addLabelIds,
removeLabelIds: this.removeLabelIds,
});
this.addLabelIds = [];
this.removeLabelIds = [];
},
},
};
</script>
<template>
<gl-form id="work-item-list-bulk-edit" class="gl-p-5" @submit.prevent="handleFormSubmitted">
<work-item-bulk-edit-labels
:form-label="__('Add labels')"
form-label-id="bulk-update-add-labels"
:full-path="fullPath"
:is-group="isGroup"
:selected-labels-ids="addLabelIds"
@select="addLabelIds = $event"
/>
<work-item-bulk-edit-labels
:checked-items="checkedItems"
:form-label="__('Remove labels')"
form-label-id="bulk-update-remove-labels"
:full-path="fullPath"
:is-group="isGroup"
:selected-labels-ids="removeLabelIds"
@select="removeLabelIds = $event"
/>
</gl-form>
</template>

View File

@ -15,15 +15,12 @@ import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
import { findLabelsWidget, newWorkItemId, newWorkItemFullPath } from '../utils';
function formatLabelForListbox(label) {
return {
text: label.title || label.text,
value: label.id || label.value,
color: label.color,
};
}
import {
findLabelsWidget,
formatLabelForListbox,
newWorkItemId,
newWorkItemFullPath,
} from '../utils';
export default {
components: {

View File

@ -90,6 +90,12 @@ export const findHierarchyWidgetChildren = (workItem) =>
export const findHierarchyWidgetAncestors = (workItem) =>
findHierarchyWidget(workItem)?.ancestors?.nodes || [];
export const formatLabelForListbox = (label) => ({
text: label.title || label.text,
value: label.id || label.value,
color: label.color,
});
export const convertTypeEnumToName = (workItemTypeEnum) =>
Object.keys(NAME_TO_ENUM_MAP).find((name) => NAME_TO_ENUM_MAP[name] === workItemTypeEnum);

View File

@ -250,24 +250,6 @@ class Packages::Package < ApplicationRecord
def detailed_info?
DETAILED_INFO_STATUSES.include?(status.to_sym)
end
private
# This method will block while another database transaction attempts to insert the same data.
# After the lock is released by the other transaction, the uniqueness validation may fail
# with record not unique validation error.
# Without this block the uniqueness validation wouldn't be able to detect duplicated
# records as transactions can't see each other's changes.
# This is a temp advisory lock to prevent race conditions. We will switch to use database `upsert`
# once we have a database unique index: https://gitlab.com/gitlab-org/gitlab/-/issues/424238#note_2187274213
def prevent_concurrent_inserts
lock_key = [self.class.table_name, project_id, name, version].join('-')
lock_expression = "hashtext(#{connection.quote(lock_key)})"
connection.execute("SELECT pg_advisory_xact_lock(#{lock_expression})")
end
end
Packages::Package.prepend_mod

View File

@ -2,7 +2,6 @@
removal_milestone: "Pending"
announcement_milestone: "17.9"
breaking_change: true
window: 1
reporter: nagyv-gitlab
stage: deploy
issue_url: https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues/630

View File

@ -28,24 +28,25 @@ and [as simple as possible](https://handbook.gitlab.com/handbook/communication/#
We provide [example diagrams and statements](#examples) to demonstrate correct usage of terms.
| Term | Definition | Scope | Discouraged synonyms |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|-------------------------------------------------|
| Node | An individual server that runs GitLab either with a specific role or as a whole (for example a Rails application node). In a cloud context this can be a specific machine type. | GitLab | instance, server |
| Site | One or a collection of nodes running a single GitLab application. A site can be single-node or multi-node. | GitLab | deployment, installation instance |
| Single-node site | A specific configuration of GitLab that uses exactly one node. | GitLab | single-server, single-instance |
| Multi-node site | A specific configuration of GitLab that uses more than one node. | GitLab | multi-server, multi-instance, high availability |
| Primary site | A GitLab site whose data is being replicated by at least one secondary site. There can only be a single primary site. | Geo-specific | Geo deployment, Primary node |
| Secondary site | A GitLab site that is configured to replicate the data of a primary site. There can be one or more secondary sites. | Geo-specific | Geo deployment, Secondary node |
| Geo deployment | A collection of two or more GitLab sites with exactly one primary site being replicated by one or more secondary sites. | Geo-specific | |
| Reference architecture | A [specified configuration of GitLab based on Requests per Second or user count](../reference_architectures/_index.md), possibly including multiple nodes and multiple sites. | GitLab | |
| Promoting | Changing the role of a site from secondary to primary. | Geo-specific | |
| Demoting | Changing the role of a site from primary to secondary. | Geo-specific | |
| Failover | The entire process that shifts users from a primary Site to a secondary site. This includes promoting a secondary, but contains other parts as well. For example, scheduling maintenance. | Geo-specific | |
| Replication | Also called "synchronization". The uni-directional process that updates a resource on a secondary site to match the resource on the primary site. | Geo-specific | |
| Verification | The process of comparing the data that exist on a primary site to the data replicated to a secondary site. Used to ensure integrity of replicated data. | Geo-specific | |
| Unified URL | A single external URL used for all Geo sites. Allows requests to be routed to either the primary Geo site or any secondary Geo sites. | Geo-specific | |
| Geo proxying | A mechanism where secondary Geo sites transparently forward operations to the primary site, except for certain operations that can be handled locally by the secondary sites. | Geo-specific | |
| Blob | Geo-related data type which can be replicated to cover various GitLab components. | Geo-specific | file |
| Term | Definition | Scope | Discouraged synonyms |
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|-------------------------------------------------|
| Node | An individual server that runs GitLab either with a specific role or as a whole (for example a Rails application node). In a cloud context this can be a specific machine type. | GitLab | instance, server |
| Site | One or a collection of nodes running a single GitLab application. A site can be single-node or multi-node. | GitLab | deployment, installation instance |
| Single-node site | A specific configuration of GitLab that uses exactly one node. | GitLab | single-server, single-instance |
| Multi-node site | A specific configuration of GitLab that uses more than one node. | GitLab | multi-server, multi-instance, high availability |
| Primary site | A GitLab site whose data is being replicated by at least one secondary site. There can only be a single primary site. | Geo-specific | Geo deployment, Primary node |
| Secondary site | A GitLab site that is configured to replicate the data of a primary site. There can be one or more secondary sites. | Geo-specific | Geo deployment, Secondary node |
| Geo deployment | A collection of two or more GitLab sites with exactly one primary site being replicated by one or more secondary sites. | Geo-specific | |
| Reference architecture | A [specified configuration of GitLab based on Requests per Second or user count](../reference_architectures/_index.md), possibly including multiple nodes and multiple sites. | GitLab | |
| Promoting | Changing the role of a site from secondary to primary. | Geo-specific | |
| Demoting | Changing the role of a site from primary to secondary. | Geo-specific | |
| Failover | The entire process that shifts users from a primary Site to a secondary site. This includes promoting a secondary, but contains other parts as well. For example, scheduling maintenance. | Geo-specific | |
| Replication | Also called "synchronization". The uni-directional process that updates a resource on a secondary site to match the resource on the primary site. | Geo-specific | |
| Replication slot | The PostgreSQL replication feature that ensures a persistent connection point with the database, and tracks which WAL segments are still needed by standby servers. It can be helpful to name replication slots to match the `geo_node_name` of a site, but this is not required. | PostgreSQL | |
| Verification | The process of comparing the data that exist on a primary site to the data replicated to a secondary site. Used to ensure integrity of replicated data. | Geo-specific | |
| Unified URL | A single external URL used for all Geo sites. Allows requests to be routed to either the primary Geo site or any secondary Geo sites. | Geo-specific | |
| Geo proxying | A mechanism where secondary Geo sites transparently forward operations to the primary site, except for certain operations that can be handled locally by the secondary sites. | Geo-specific | |
| Blob | Geo-related data type which can be replicated to cover various GitLab components. | Geo-specific | file |
## Replicator terms

View File

@ -49,7 +49,8 @@ are [paginated](rest/_index.md#pagination).
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/project_repository_storage_moves"
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/project_repository_storage_moves"
```
Example response:
@ -93,7 +94,8 @@ Parameters:
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/repository_storage_moves"
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/projects/1/repository_storage_moves"
```
Example response:
@ -134,7 +136,8 @@ Parameters:
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/project_repository_storage_moves/1"
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/project_repository_storage_moves/1"
```
Example response:
@ -174,7 +177,8 @@ Parameters:
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/repository_storage_moves/1"
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/projects/1/repository_storage_moves/1"
```
Example response:
@ -214,9 +218,11 @@ Parameters:
Example request:
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" \
--data '{"destination_storage_name":"storage2"}' \
"https://gitlab.example.com/api/v4/projects/1/repository_storage_moves"
curl --request POST \
--header "PRIVATE-TOKEN: <your_access_token>" \
--header "Content-Type: application/json" \
--data '{"destination_storage_name":"storage2"}' \
--url "https://gitlab.example.com/api/v4/projects/1/repository_storage_moves"
```
Example response:
@ -260,9 +266,11 @@ Parameters:
Example request:
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" \
--data '{"source_storage_name":"default"}' \
"https://gitlab.example.com/api/v4/project_repository_storage_moves"
curl --request POST \
--header "PRIVATE-TOKEN: <your_access_token>" \
--header "Content-Type: application/json" \
--data '{"source_storage_name":"default"}' \
--url "https://gitlab.example.com/api/v4/project_repository_storage_moves"
```
Example response:

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -110,3 +110,17 @@ are running a supported version of PyCharm:
1. For **Compatibility**, select `PyCharm Community` or `PyCharm Professional`.
1. For **Channels**, select your desired stability level for the GitLab plugin.
1. For your version of PyCharm, select **Download** to download the correct GitLab plugin version, and install it.
## JCEF Errors
If you experience issues with GitLab Duo Chat related to JCEF (Java Chromium Embedded Framework), you can try these steps:
1. On the top bar, go to **Help > Find Action** and search for `Registry`.
1. Find or search for `ide.browser.jcef.sandbox.enable`.
1. Clear the checkbox to disable this setting.
1. Close the Registry dialog.
1. Restart your IDE.
1. On the top bar, go to **Help > Find Action** and search for `Choose Boot Java Runtime for the IDE`.
1. Select the boot java runtime version that's the same as your current IDE version, but with JCEF bundled:
![JCEF supporting runtime example](img/jcef_supporting_runtime_example_v17_3.png)
1. Restart your IDE.

View File

@ -333,6 +333,13 @@ Additionally, restricted access might block the standard non-overage flows:
## User cap for groups
{{< details >}}
- Tier: Premium, Ultimate
- Offering: GitLab.com
{{< /details >}}
{{< history >}}
- [Enabled on GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/9263) in GitLab 16.3.

View File

@ -566,6 +566,13 @@ Fruits
: orange
```
{{< alert type="note" >}}
The rich text editor does not support inserting new description lists. To insert a new description list, use the
plain text editor. For more information, see [issue 535956](https://gitlab.com/gitlab-org/gitlab/-/issues/535956).
{{< /alert >}}
### Task lists
{{< history >}}
@ -1584,6 +1591,13 @@ $$
![Example of math in GitLab](img/markdown_math_v17_2.png)
{{< alert type="note" >}}
The rich text editor does not support inserting new math blocks. To insert a new math block, use the
plain text editor. For more information, see [issue 366527](https://gitlab.com/gitlab-org/gitlab/-/issues/366527).
{{< /alert >}}
## Table of contents
A table of contents is an unordered list that links to subheadings in the document.
@ -2063,6 +2077,13 @@ These are used to force the Vale ReferenceLinks check to skip these examples.
[^footnote-42]: This text is another footnote.
{{< alert type="note" >}}
The rich text editor does not support inserting new footnotes. To insert a new footnote, use the
plain text editor. For more information, see [issue 365265](https://gitlab.com/gitlab-org/gitlab/-/issues/365265).
{{< /alert >}}
## Inline HTML
[View this topic rendered in GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/user/markdown.md#inline-html).

View File

@ -48,9 +48,34 @@ container_scanning:
rules:
- if: $CONTAINER_SCANNING_DISABLED == 'true' || $CONTAINER_SCANNING_DISABLED == '1'
when: never
# The following 3 blocks of rules define whether the job runs in a an *MR pipeline* or a *branch pipeline*
# when an MR exists. If the job has additional rules to observe they should be added in the blocks 1 and 3
# to cover both the *MR pipeline* and the *branch pipeline* workflows.
# 1. Run the job in an *MR pipeline* if MR pipelines for AST are enabled and there's an open merge request.
## When FIPS mode is enabled, use the FIPS compatible image
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_PIPELINE_SOURCE == "merge_request_event" &&
$CI_GITLAB_FIPS_MODE == "true" &&
$CS_ANALYZER_IMAGE !~ /-(fips|ubi)\z/
variables:
CS_IMAGE_SUFFIX: -fips
## When FIPS mode is not enabled, use the regular image
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_PIPELINE_SOURCE == "merge_request_event"
# 2. Don't run the job in a *branch pipeline* if *MR pipelines* for AST are enabled and there's an open merge request.
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_OPEN_MERGE_REQUESTS
when: never
# 3. Finally, run the job in a *branch pipeline* (When MR pipelines are disabled for AST, or it is enabled but no open MRs exist for the branch).
## When FIPS mode is enabled, use the FIPS compatible image
- if: $CI_COMMIT_BRANCH &&
$CI_GITLAB_FIPS_MODE == "true" &&
$CS_ANALYZER_IMAGE !~ /-(fips|ubi)\z/
variables:
CS_IMAGE_SUFFIX: -fips
## When FIPS mode is not enabled, use the regular image
- if: $CI_COMMIT_BRANCH

View File

@ -26,6 +26,10 @@
# List of available variables: https://docs.gitlab.com/ee/user/application_security/container_scanning/#available-variables
variables:
# Setting this variable affects all Security templates
# (SAST, Dependency Scanning, ...)
AST_ENABLE_MR_PIPELINES: "true"
#
CS_ANALYZER_IMAGE: "$CI_TEMPLATE_REGISTRY_HOST/security-products/container-scanning:7"
CS_SCHEMA_MODEL: 15
@ -54,24 +58,35 @@ variables:
- if: $CONTAINER_SCANNING_DISABLED == 'true' || $CONTAINER_SCANNING_DISABLED == '1'
when: never
# Add the job to merge request pipelines if there's an open merge request.
- if: $CI_PIPELINE_SOURCE == "merge_request_event" &&
# The following 3 blocks of rules define whether the job runs in a an *MR pipeline* or a *branch pipeline*
# when an MR exists. If the job has additional rules to observe they should be added in the blocks 1 and 3
# to cover both the *MR pipeline* and the *branch pipeline* workflows.
# 1. Run the job in an *MR pipeline* if MR pipelines for AST are enabled and there's an open merge request.
## When FIPS mode is enabled, use the FIPS compatible image
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_PIPELINE_SOURCE == "merge_request_event" &&
$CI_GITLAB_FIPS_MODE == "true" &&
$CS_ANALYZER_IMAGE !~ /-(fips|ubi)\z/
variables:
CS_IMAGE_SUFFIX: -fips
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
## When FIPS mode is not enabled, use the regular image
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_PIPELINE_SOURCE == "merge_request_event"
# Don't add it to a *branch* pipeline if it's already in a merge request pipeline.
- if: $CI_OPEN_MERGE_REQUESTS
# 2. Don't run the job in a *branch pipeline* if *MR pipelines* for AST are enabled and there's an open merge request.
- if: $AST_ENABLE_MR_PIPELINES == "true" &&
$CI_OPEN_MERGE_REQUESTS
when: never
# Add the job to branch pipelines.
# 3. Finally, run the job in a *branch pipeline* (When MR pipelines are disabled for AST, or it is enabled but no open MRs exist for the branch).
## When FIPS mode is enabled, use the FIPS compatible image
- if: $CI_COMMIT_BRANCH &&
$CI_GITLAB_FIPS_MODE == "true" &&
$CS_ANALYZER_IMAGE !~ /-(fips|ubi)\z/
variables:
CS_IMAGE_SUFFIX: -fips
## When FIPS mode is not enabled, use the regular image
- if: $CI_COMMIT_BRANCH
container_scanning:

View File

@ -50204,6 +50204,9 @@ msgstr ""
msgid "Remove iteration"
msgstr ""
msgid "Remove labels"
msgstr ""
msgid "Remove license"
msgstr ""

View File

@ -30,6 +30,8 @@ describe('OpenMrBadge', () => {
blobPath: 'path/to/file.js',
};
useFakeDate('2020-04-15 09:00:00 GMT+2');
function createComponent(
props = {},
mockResolver = openMRQueryResult,
@ -81,8 +83,6 @@ describe('OpenMrBadge', () => {
});
describe('computed properties', () => {
useFakeDate();
beforeEach(() => {
createComponent({});
});
@ -90,7 +90,7 @@ describe('OpenMrBadge', () => {
it('computes queryVariables correctly', () => {
expect(openMrsCountQueryHandler).toHaveBeenCalledWith({
blobPath: 'path/to/file.js',
createdAfter: '2020-06-07',
createdAfter: '2020-03-17 07:00:00 UTC',
projectPath: 'group/project',
targetBranch: ['main'],
});
@ -136,7 +136,7 @@ describe('OpenMrBadge', () => {
expect(openMrsQueryHandler).toHaveBeenCalledWith({
blobPath: 'path/to/file.js',
createdAfter: '2020-06-07',
createdAfter: '2020-03-17 07:00:00 UTC',
projectPath: 'group/project',
targetBranch: ['main'],
});

View File

@ -0,0 +1,228 @@
import { GlButton, GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { projectLabelsResponse } from 'jest/work_items/mock_data';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
import WorkItemBulkEditLabels from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_labels.vue';
import { WIDGET_TYPE_LABELS } from '~/work_items/constants';
jest.mock('~/sentry/sentry_browser_wrapper');
Vue.use(VueApollo);
describe('WorkItemBulkEditLabels component', () => {
let wrapper;
const labelsManagePath = '/labels/manage/path';
const labels = cloneDeep(projectLabelsResponse);
labels.data.workspace.labels.nodes.push({
__typename: 'Label',
id: 'gid://gitlab/Label/4',
title: 'Label 4',
description: 'Label 4 description',
color: '#fff',
textColor: '#000',
});
const projectLabelsQueryHandler = jest.fn().mockResolvedValue(labels);
const createComponent = ({ props = {}, searchQueryHandler = projectLabelsQueryHandler } = {}) => {
wrapper = shallowMount(WorkItemBulkEditLabels, {
apolloProvider: createMockApollo([[projectLabelsQuery, searchQueryHandler]]),
propsData: {
formLabel: 'Labels',
formLabelId: 'labels-id',
fullPath: 'group/project',
...props,
},
provide: {
labelsManagePath,
},
stubs: {
GlCollapsibleListbox,
},
});
};
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findManageLabelsButton = () => wrapper.findComponent(GlButton);
it('renders the form group', () => {
createComponent();
expect(findFormGroup().attributes('label')).toBe('Labels');
});
it('connects the form group and the listbox', () => {
createComponent();
expect(findFormGroup().attributes('label-for')).toBe(findListbox().props('toggleId'));
});
it('renders the manage labels button with correct text for project', () => {
createComponent();
expect(findManageLabelsButton().text()).toBe('Manage project labels');
expect(findManageLabelsButton().attributes('href')).toBe(labelsManagePath);
});
describe('search labels query', () => {
it('is not called before dropdown is shown', () => {
createComponent();
expect(projectLabelsQueryHandler).not.toHaveBeenCalled();
});
it('is called when dropdown is shown', async () => {
createComponent();
findListbox().vm.$emit('shown');
await nextTick();
expect(projectLabelsQueryHandler).toHaveBeenCalled();
});
it('emits an error when there is an error in the call', async () => {
createComponent({ searchQueryHandler: jest.fn().mockRejectedValue(new Error('error!')) });
findListbox().vm.$emit('shown');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong when fetching labels. Please try again.'],
]);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('error!'));
});
});
describe('listbox items', () => {
describe('with no selected labels', () => {
it('renders all labels', async () => {
createComponent();
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('items')).toEqual([
expect.objectContaining({ text: 'Label 1' }),
expect.objectContaining({ text: 'Label::2' }),
expect.objectContaining({ text: 'Label 3' }),
expect.objectContaining({ text: 'Label 4' }),
]);
});
});
describe('with selected labels', () => {
it('renders a "Selected" group and an "All" group', async () => {
createComponent();
findListbox().vm.$emit('select', ['gid://gitlab/Label/2']);
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('items')).toEqual([
{
text: 'Selected',
options: [expect.objectContaining({ text: 'Label::2' })],
},
{
text: 'All',
textSrOnly: true,
options: [
expect.objectContaining({ text: 'Label 1' }),
expect.objectContaining({ text: 'Label::2' }),
expect.objectContaining({ text: 'Label 3' }),
expect.objectContaining({ text: 'Label 4' }),
],
},
]);
});
});
describe('with checked items', () => {
it('only renders labels from the checked items', async () => {
const checkedItems = [
{
widgets: [
{
type: WIDGET_TYPE_LABELS,
labels: { nodes: [{ id: 'gid://gitlab/Label/1', title: 'Label 1' }] },
},
],
},
{
widgets: [
{
type: WIDGET_TYPE_LABELS,
labels: {
nodes: [
{ id: 'gid://gitlab/Label/1', title: 'Label 1' },
{ id: 'gid://gitlab/Label/3', title: 'Label 3' },
],
},
},
],
},
];
createComponent({ props: { checkedItems } });
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('items')).toEqual([
expect.objectContaining({ text: 'Label 1' }),
expect.objectContaining({ text: 'Label 3' }),
]);
});
});
});
describe('listbox text', () => {
describe('with no selected labels', () => {
it('renders "Select labels"', () => {
createComponent();
expect(findListbox().props('toggleText')).toBe('Select labels');
});
});
describe('with fewer than 4 selected labels', () => {
it('renders all label titles', async () => {
createComponent({
props: { selectedLabelsIds: ['gid://gitlab/Label/2', 'gid://gitlab/Label/3'] },
});
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('toggleText')).toBe('Label::2 and Label 3');
});
});
describe('with more than 3 selected labels', () => {
it('renders first label title followed by the count', async () => {
createComponent({
props: {
selectedLabelsIds: [
'gid://gitlab/Label/1',
'gid://gitlab/Label/2',
'gid://gitlab/Label/3',
'gid://gitlab/Label/4',
],
},
});
findListbox().vm.$emit('shown');
await waitForPromises();
expect(findListbox().props('toggleText')).toBe('Label 1, and 3 more');
});
});
});
});

View File

@ -0,0 +1,95 @@
import { GlForm } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import WorkItemBulkEditLabels from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_labels.vue';
import WorkItemBulkEditSidebar from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue';
describe('WorkItemBulkEditSidebar component', () => {
let wrapper;
const checkedItems = [
{ id: 'gid://gitlab/WorkItem/1', title: 'Work Item 1' },
{ id: 'gid://gitlab/WorkItem/2', title: 'Work Item 2' },
];
const createComponent = () => {
wrapper = shallowMount(WorkItemBulkEditSidebar, {
propsData: {
checkedItems,
fullPath: 'group/project',
isGroup: false,
},
});
};
const findForm = () => wrapper.findComponent(GlForm);
const findAddLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(0);
const findRemoveLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(1);
beforeEach(() => {
createComponent();
});
describe('form', () => {
it('renders', () => {
expect(findForm().attributes('id')).toBe('work-item-list-bulk-edit');
});
it('emits "bulk-update" event when submitted', () => {
const addLabelIds = ['gid://gitlab/Label/1'];
const removeLabelIds = ['gid://gitlab/Label/2'];
findAddLabelsComponent().vm.$emit('select', addLabelIds);
findRemoveLabelsComponent().vm.$emit('select', removeLabelIds);
findForm().vm.$emit('submit', { preventDefault: () => {} });
expect(wrapper.emitted('bulk-update')).toEqual([
[
{
ids: checkedItems.map((item) => item.id),
addLabelIds,
removeLabelIds,
},
],
]);
expect(findAddLabelsComponent().props('selectedLabelsIds')).toEqual([]);
expect(findRemoveLabelsComponent().props('selectedLabelsIds')).toEqual([]);
});
});
describe('"Add labels" component', () => {
it('renders', () => {
expect(findAddLabelsComponent().props()).toMatchObject({
formLabel: 'Add labels',
formLabelId: 'bulk-update-add-labels',
});
});
it('updates labels to add when "Add labels" component emits "select" event', async () => {
const labelIds = ['gid://gitlab/Label/1', 'gid://gitlab/Label/2'];
findAddLabelsComponent().vm.$emit('select', labelIds);
await nextTick();
expect(findAddLabelsComponent().props('selectedLabelsIds')).toEqual(labelIds);
});
});
describe('"Remove labels" component', () => {
it('renders', () => {
expect(findRemoveLabelsComponent().props()).toMatchObject({
formLabel: 'Remove labels',
formLabelId: 'bulk-update-remove-labels',
});
});
it('updates labels to remove when "Remove labels" component emits "select" event', async () => {
const labelIds = ['gid://gitlab/Label/1', 'gid://gitlab/Label/2'];
findRemoveLabelsComponent().vm.$emit('select', labelIds);
await nextTick();
expect(findRemoveLabelsComponent().props('selectedLabelsIds')).toEqual(labelIds);
});
});
});

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'spec_helper'
# These shared_contexts and shared_examples are used to test the # CI/CD templates
# powering the Application Security Testing (AST) features.
# There is a lot of repitition across these templates and the setup for these
# specs is expensive.
#
# Usually, each template will have its behavior tested in these 3 different pipelines types:
# - default branch pipeline
# - feature branch pipeline
# - MR pipeline
#
# Additionally, some templates have CI jobs using rules:exists which involves setting up
# a project with a repository that contains specific files. To improve speed and
# efficiency, the setup steps are extracted into shared_context that use let_it_be and
# before(:context). This ensures to create the project only once per scenario.
# Though these contexts assume a particular usage which reduces flexibility.
# Please check existing specs for examples.
RSpec.shared_context 'with CI variables' do |variables|
before do
variables.each do |(key, value)|
create(:ci_variable, project: project, key: key, value: value)
end
end
end
RSpec.shared_context 'with default branch pipeline setup' do
let_it_be(:pipeline_branch) { default_branch }
let_it_be(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch) }
end
RSpec.shared_context 'with feature branch pipeline setup' do
let_it_be(:pipeline_branch) { feature_branch }
let_it_be(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch) }
before(:context) do
project.repository.create_file(
project.creator,
'branch.patch',
'',
message: "Add file to feature branch",
branch_name: pipeline_branch,
# Ensure new branch includes expected project files from default branch.
start_branch_name: default_branch)
end
end
RSpec.shared_context 'with MR pipeline setup' do
let_it_be(:pipeline_branch) { 'mr_branch' }
let_it_be(:service) { MergeRequests::CreatePipelineService.new(project: project, current_user: user) }
let(:pipeline) { service.execute(merge_request).payload }
let_it_be(:merge_request) do
# Ensure MR has at least one commit otherwise MR pipeline won't be triggered.
# This also seems to be required to happen before the MR creation.
project.repository.create_file(
project.creator,
'MR.patch',
'',
message: "Add patch to MR branch",
branch_name: pipeline_branch,
# Ensure new branch includes expected project files from default branch.
start_branch_name: default_branch)
create(:merge_request,
source_project: project,
source_branch: pipeline_branch,
target_project: project,
target_branch: default_branch)
end
end
RSpec.shared_examples 'has expected jobs' do |jobs|
it 'includes jobs', if: jobs.any? do
expect(pipeline.builds.pluck(:name)).to match_array(jobs)
# TODO: Failing for DAST related templates with error:
# "Insufficient permissions for dast_configuration keyword"
# expect(pipeline.errors.full_messages).to be_empty unless ignore_errors
end
it 'includes no jobs', if: jobs.empty? do
expect(pipeline.builds.pluck(:name)).to be_empty
expect(pipeline.errors.full_messages).to match_array(
[sanitize_message(Ci::Pipeline.rules_failure_message)])
end
end
RSpec.shared_examples 'has FIPS compatible jobs' do |variable, jobs|
context 'when CI_GITLAB_FIPS_MODE=false', fips_mode: false do
jobs.each do |job|
it "sets #{variable} to '' for job #{job}" do
build = pipeline.builds.find_by(name: job)
expect(String(build.variables.to_hash[variable])).to eql('')
end
end
end
context 'when CI_GITLAB_FIPS_MODE=true', :fips_mode do
jobs.each do |job|
it "sets #{variable} to '-fips' for job #{job}" do
build = pipeline.builds.find_by(name: job)
expect(String(build.variables.to_hash[variable])).to eql('-fips')
end
end
end
end
RSpec.shared_examples 'has jobs that can be disabled' do |key, disabled_values, jobs|
disabled_values.each do |disabled_value|
context "when #{key} is set to '#{disabled_value}'" do
before do
create(:ci_variable, project: project, key: key, value: disabled_value)
end
include_examples 'has expected jobs', []
end
end
# This ensures we don't accidentally disable jobs when user sets the variable to 'false'.
context "when #{key} is set to 'false'" do
before do
create(:ci_variable, project: project, key: key, value: 'false')
end
include_examples 'has expected jobs', jobs
end
end