Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-01-16 12:08:54 +00:00
parent 65d9a877b3
commit 218585fc85
42 changed files with 639 additions and 162 deletions

View File

@ -1,6 +1,14 @@
---
include:
- local: .gitlab/ci/cng/main.gitlab-ci.yml
- project: 'gitlab-org/quality/pipeline-common'
ref: master
file: ci/base.gitlab-ci.yml
stages:
- prepare
- deploy
- qa
release-environments-build-cng-env:
extends: .build-cng-env
@ -42,3 +50,15 @@ release-environments-deploy:
project: gitlab-com/gl-infra/release-environments
branch: main
strategy: depend
release-environments-qa:
stage: qa
extends:
- .qa-base
timeout: 3h
variables:
QA_SCENARIO: "Test::Instance::Smoke"
RELEASE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab-ee-qa:${CI_COMMIT_SHA}"
GITLAB_QA_OPTS: --address "https://gitlab.${ENVIRONMENT}.release.gke.gitlab.net"
GITLAB_INITIAL_ROOT_PASSWORD: "${RELEASE_ENVIRONMENTS_ROOT_PASSWORD}"
SIGNUP_DISABLED: "true"

View File

@ -5,8 +5,13 @@ import {
GlIcon,
GlButtonGroup,
GlButton,
GlTooltipDirective as GlTooltip,
GlLink,
GlTooltip,
GlTooltipDirective,
GlSprintf,
} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
components: {
@ -15,11 +20,22 @@ export default {
GlDisclosureDropdownItem,
GlButtonGroup,
GlButton,
GlLink,
GlTooltip,
GlSprintf,
},
directives: {
GlTooltip,
GlTooltip: GlTooltipDirective,
},
projectCreationHelp: helpPagePath('user/group/import/index', {
anchor: 'ensure-projects-can-be-imported',
}),
props: {
id: {
type: Number,
required: false,
default: null,
},
isFinished: {
type: Boolean,
required: true,
@ -32,7 +48,28 @@ export default {
type: Boolean,
required: true,
},
isProjectCreationAllowed: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
showImportActions() {
return this.isAvailableForImport || this.isFinished;
},
showImportWithoutProjectsWarning() {
return this.showImportActions && !this.isProjectCreationAllowed;
},
importWithProjectsText() {
return this.isFinished ? __('Re-import with projects') : __('Import with projects');
},
importWithoutProjectsText() {
return this.isFinished ? __('Re-import without projects') : __('Import without projects');
},
},
methods: {
importGroup(extraArgs = {}) {
this.$emit('import-group', extraArgs);
@ -42,41 +79,82 @@ export default {
</script>
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-button-group v-if="isAvailableForImport || isFinished">
<div class="gl-white-space-nowrap gl-display-inline-flex gl-align-items-center gl-gap-3">
<template v-if="isProjectCreationAllowed">
<gl-button-group v-if="showImportActions">
<gl-button
variant="confirm"
category="secondary"
data-testid="import-group-button"
@click="importGroup({ migrateProjects: true })"
>{{ importWithProjectsText }}</gl-button
>
<gl-disclosure-dropdown
toggle-text="Import options"
text-sr-only
:disabled="isInvalid"
icon="chevron-down"
no-caret
variant="confirm"
category="secondary"
>
<gl-disclosure-dropdown-item @action="importGroup({ migrateProjects: false })">
<template #list-item>
{{ importWithoutProjectsText }}
</template></gl-disclosure-dropdown-item
>
</gl-disclosure-dropdown>
</gl-button-group>
</template>
<template v-else>
<gl-button
v-if="showImportActions"
variant="confirm"
category="secondary"
data-testid="import-group-button"
@click="importGroup({ migrateProjects: true })"
>{{ isFinished ? __('Re-import with projects') : __('Import with projects') }}</gl-button
@click="importGroup({ migrateProjects: false })"
>
<gl-disclosure-dropdown
toggle-text="Import options"
text-sr-only
:disabled="isInvalid"
icon="chevron-down"
no-caret
variant="confirm"
category="secondary"
>
<gl-disclosure-dropdown-item @action="importGroup({ migrateProjects: false })">
<template #list-item>
{{ isFinished ? __('Re-import without projects') : __('Import without projects') }}
</template></gl-disclosure-dropdown-item
>
</gl-disclosure-dropdown>
</gl-button-group>
{{ importWithoutProjectsText }}
</gl-button>
</template>
<gl-icon
v-if="isFinished"
v-gl-tooltip
:size="16"
name="information-o"
data-testid="reimport-info-icon"
:title="
s__('BulkImport|Re-import creates a new group. It does not sync with the existing group.')
"
class="gl-ml-3"
/>
</span>
<div v-if="showImportWithoutProjectsWarning" :id="`tooltip-description-container-${id}`">
<span ref="projectCreationWarning">
<gl-icon
name="warning"
class="gl-text-orange-500"
data-testid="project-creation-warning-icon"
/>
</span>
<gl-tooltip
:target="() => $refs.projectCreationWarning"
:container="`tooltip-description-container-${id}`"
>
<gl-sprintf
:message="
s__(
`BulkImport|Because of settings on the source GitLab instance or group, you can't import projects with this group. To permit importing projects with this group, reconfigure the source GitLab instance or group. %{linkStart}Learn more.%{linkEnd}`,
)
"
>
<template #link="{ content }">
<gl-link :href="$options.projectCreationHelp" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-tooltip>
</div>
</div>
</template>

View File

@ -33,7 +33,13 @@ import updateImportStatusMutation from '../graphql/mutations/update_import_statu
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants';
import { StatusPoller } from '../services/status_poller';
import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils';
import {
isFinished,
isAvailableForImport,
isNameValid,
isProjectCreationAllowed,
isSameTarget,
} from '../utils';
import ImportActionsCell from './import_actions_cell.vue';
import ImportHistoryLink from './import_history_link.vue';
import ImportSourceCell from './import_source_cell.vue';
@ -175,6 +181,7 @@ export default {
isAvailableForImport: isGroupAvailableForImport,
isAllowedForReimport: false,
isFinished: isFinished(group),
isProjectCreationAllowed: isProjectCreationAllowed(importTarget?.targetNamespace),
};
return {
@ -197,6 +204,16 @@ export default {
return this.selectedGroupsIds.length === this.availableGroupsForImport.length;
},
showImportProjectsWarning() {
return (
this.hasSelectedGroups &&
this.groupsTableData.some(
(group) =>
this.selectedGroupsIds.includes(group.id) && !group.flags.isProjectCreationAllowed,
)
);
},
availableGroupsForImport() {
return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && !g.flags.isInvalid);
},
@ -362,7 +379,16 @@ export default {
const newImportTarget = {
...group.importTarget,
...changes,
...(changes.targetNamespace
? {
targetNamespace: {
...(this.availableNamespaces.find((g) => g.id === changes.targetNamespace.id) ||
changes.targetNamespace),
},
}
: {}),
};
this.$set(this.importTargets, group.id, newImportTarget);
this.validateImportTarget(newImportTarget);
},
@ -734,6 +760,16 @@ export default {
{{ s__('BulkImport|Import without projects') }}
</gl-dropdown-item>
</gl-dropdown>
<span v-if="showImportProjectsWarning" class="gl-ml-3">
<gl-icon
v-gl-tooltip
:title="s__('BulkImport|Some groups will be imported without projects.')"
name="warning"
class="gl-text-orange-500"
data-testid="import-projects-warning"
/>
</span>
<span class="gl-ml-3">
<gl-icon name="information-o" :size="12" class="gl-text-blue-600" />
<gl-sprintf
@ -814,9 +850,11 @@ export default {
</template>
<template #cell(actions)="{ item: group, index }">
<import-actions-cell
:id="group.id"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
:is-project-creation-allowed="group.flags.isProjectCreationAllowed"
@import-group="importGroup({ group, extraArgs: $event, index })"
/>
</template>

View File

@ -17,6 +17,10 @@ export function isAvailableForImport(group) {
return !group.progress || isFinished(group);
}
export function isProjectCreationAllowed(group) {
return Boolean(group.projectCreationLevel) && group.projectCreationLevel !== 'noone';
}
export function isSameTarget(importTarget) {
return (target) =>
target !== importTarget &&

View File

@ -6,6 +6,7 @@ query searchNamespacesWhereUserCanImportProjects($search: String) {
id
fullPath
name
projectCreationLevel
visibility
webUrl
}

View File

@ -1,6 +1,9 @@
import { addShortcutsExtension } from '~/behaviors/shortcuts';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initReadMore from '~/read_more';
import Project from './project';
new Project(); // eslint-disable-line no-new
addShortcutsExtension(ShortcutsNavigation);
initReadMore();

View File

@ -78,7 +78,7 @@ export default {
</script>
<template>
<div class="gl-p-5 gl-border-b gl-border-gray-50">
<div class="ref-list gl-p-5 gl-border-b-solid gl-border-b-1">
<gl-icon :name="refIcon" :size="14" class="gl-ml-2 gl-mr-3" />
<span data-testid="title" class="gl-mr-2">{{ namespace }}</span>
<gl-badge

View File

@ -16,6 +16,8 @@
* </div>
* <button class="js-read-more-trigger">Read more</button>
*
* If data-read-more-height is present it will use it to determine if the button should be shown or not.
*
*/
export default function initReadMore(triggerSelector = '.js-read-more-trigger') {
const triggerEls = document.querySelectorAll(triggerSelector);
@ -29,6 +31,24 @@ export default function initReadMore(triggerSelector = '.js-read-more-trigger')
return;
}
if (Object.hasOwn(triggerEl.parentNode.dataset, 'readMoreHeight')) {
const parentEl = triggerEl.parentNode;
const readMoreHeight = Number(parentEl.dataset.readMoreHeight);
const readMoreContent = parentEl.querySelector('.read-more-content');
if (readMoreContent) {
parentEl.style.setProperty('--read-more-height', `${readMoreHeight}px`);
}
if (readMoreHeight > readMoreContent.clientHeight) {
readMoreContent.classList.remove('read-more-content--has-scrim');
triggerEl.remove();
return;
}
triggerEl.classList.remove('gl-display-none');
}
triggerEl.addEventListener(
'click',
() => {

View File

@ -117,6 +117,10 @@ input[type='file'] {
border-radius: 4px;
margin-bottom: 16px;
.ref-list {
border-color: $well-inner-border;
}
.well-segment {
padding: 1rem;

View File

@ -1,13 +1,41 @@
.read-more-container {
@include media-breakpoint-down(md) {
&:not(.is-expanded) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
$scrim: 2rem;
$fallback: 320px;
$height: 39px;
> * {
display: inline;
&:not(:has(.read-more-content)) {
@include media-breakpoint-down(md) {
&:not(.is-expanded) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> * {
display: inline;
}
}
}
}
&:has(.read-more-content--has-scrim:not(.is-expanded)) {
position: relative;
max-height: var(--read-more-height, #{$fallback - $height});
overflow: hidden;
}
// only appears when size is > $height.
.read-more-content--has-scrim:not(.is-expanded)::after {
content: '';
display: block;
position: absolute;
left: 0;
right: 0;
top: calc(var(--read-more-height, #{$fallback}) - #{$scrim} - #{$height});
height: $scrim;
background: linear-gradient(180deg, transparent, $white);
.gl-dark & {
background: linear-gradient(180deg, transparent, $black);
}
}
}

View File

@ -21,6 +21,8 @@ module Ci
has_many :sync_events, class_name: 'Ci::Catalog::Resources::SyncEvent', foreign_key: :catalog_resource_id,
inverse_of: :catalog_resource
enum verification_level: { unverified: 0, gitlab: 1 }
scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
# The `search_vector` column contains a tsvector that has a greater weight on `name` than `description`.

View File

@ -15,8 +15,13 @@
-# Project description
- if @project.description.present?
.home-panel-description.text-break
.home-panel-description-markdown{ itemprop: 'description' }
= markdown_field(@project, :description)
.home-panel-description-markdown.read-more-container{ itemprop: 'description', data: { 'read-more-height': 320 } }
.read-more-content.read-more-content--has-scrim
= markdown_field(@project, :description)
.js-read-more-trigger.gl-display-none.gl-w-full.gl-h-8.gl-absolute.gl-bottom-0.gl-z-index-2.gl-bg-white
= render Pajamas::ButtonComponent.new(variant: :link, button_options: { 'aria-label': _("Expand project information") }) do
= sprite_icon('chevron-down', size: 14)
= _("Read more")
-# Topics
- if @project.topics.present?

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddVerificationLevelToCatalogResources < Gitlab::Database::Migration[2.2]
milestone '16.9'
def change
add_column :catalog_resources, :verification_level, :integer, limit: 2, default: 0
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddIndexOnPipelineMetadata < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '16.8'
INDEX_NAME = 'index_pipeline_metadata_on_name_text_pattern_pipeline_id'
def up
add_concurrent_index :ci_pipeline_metadata, 'name text_pattern_ops, pipeline_id', name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :ci_pipeline_metadata, INDEX_NAME
end
end

View File

@ -0,0 +1 @@
1916f6d5ef528395a6b22071b43b22828157fd1fa838f873af9a2cb0a5cd520a

View File

@ -0,0 +1 @@
a958396844512a947a19df2ecceec3254d80154b1fb36bd97b4591d446e98450

View File

@ -14040,7 +14040,8 @@ CREATE TABLE catalog_resources (
name character varying,
description text,
visibility_level integer DEFAULT 0 NOT NULL,
search_vector tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('english'::regconfig, (COALESCE(name, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('english'::regconfig, COALESCE(description, ''::text)), 'B'::"char"))) STORED
search_vector tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('english'::regconfig, (COALESCE(name, ''::character varying))::text), 'A'::"char") || setweight(to_tsvector('english'::regconfig, COALESCE(description, ''::text)), 'B'::"char"))) STORED,
verification_level smallint DEFAULT 0
);
CREATE SEQUENCE catalog_resources_id_seq
@ -34761,6 +34762,8 @@ CREATE UNIQUE INDEX index_personal_access_tokens_on_token_digest ON personal_acc
CREATE INDEX index_personal_access_tokens_on_user_id ON personal_access_tokens USING btree (user_id);
CREATE INDEX index_pipeline_metadata_on_name_text_pattern_pipeline_id ON ci_pipeline_metadata USING btree (name text_pattern_ops, pipeline_id);
CREATE INDEX index_pipeline_metadata_on_pipeline_id_name_text_pattern ON ci_pipeline_metadata USING btree (pipeline_id, name text_pattern_ops);
CREATE UNIQUE INDEX index_pipeline_variables_on_pipeline_id_key_partition_id_unique ON ci_pipeline_variables USING btree (pipeline_id, key, partition_id);

View File

@ -1383,6 +1383,7 @@ Input type: `AiActionInput`
| <a id="mutationaiactionexplainvulnerability"></a>`explainVulnerability` | [`AiExplainVulnerabilityInput`](#aiexplainvulnerabilityinput) | Input for explain_vulnerability AI action. |
| <a id="mutationaiactionfillinmergerequesttemplate"></a>`fillInMergeRequestTemplate` | [`AiFillInMergeRequestTemplateInput`](#aifillinmergerequesttemplateinput) | Input for fill_in_merge_request_template AI action. |
| <a id="mutationaiactiongeneratecommitmessage"></a>`generateCommitMessage` | [`AiGenerateCommitMessageInput`](#aigeneratecommitmessageinput) | Input for generate_commit_message AI action. |
| <a id="mutationaiactiongeneratecubequery"></a>`generateCubeQuery` | [`AiGenerateCubeQueryInput`](#aigeneratecubequeryinput) | Input for generate_cube_query AI action. |
| <a id="mutationaiactiongeneratedescription"></a>`generateDescription` | [`AiGenerateDescriptionInput`](#aigeneratedescriptioninput) | Input for generate_description AI action. |
| <a id="mutationaiactionresolvevulnerability"></a>`resolveVulnerability` | [`AiResolveVulnerabilityInput`](#airesolvevulnerabilityinput) | Input for resolve_vulnerability AI action. |
| <a id="mutationaiactionsummarizecomments"></a>`summarizeComments` | [`AiSummarizeCommentsInput`](#aisummarizecommentsinput) | Input for summarize_comments AI action. |
@ -34042,6 +34043,15 @@ see the associated mutation type above.
| ---- | ---- | ----------- |
| <a id="aigeneratecommitmessageinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
### `AiGenerateCubeQueryInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="aigeneratecubequeryinputquestion"></a>`question` | [`String!`](#string) | Question to ask a project's data. |
| <a id="aigeneratecubequeryinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
### `AiGenerateDescriptionInput`
#### Arguments

View File

@ -458,7 +458,7 @@ module EE
module IssuePolicy
extend ActiveSupport::Concern
prepended do
with_scope :subject
with_scope :global
condition(:ai_available) do
::Feature.enabled?(:ai_global_switch, type: :ops)
end

View File

@ -71,7 +71,7 @@ To create a new application for a group:
[in GitLab 14.2 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/332844).
- The **Renew secret** function in [GitLab 15.9 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/338243). Use this function to generate and copy a new secret for this application. Renewing a secret prevents the existing application from functioning until the credentials are updated.
## Create an instance-wide application
## Create an instance-wide application **(FREE SELF)**
To create an application for your GitLab instance:

View File

@ -29,6 +29,7 @@ GitLab is creating AI-assisted features across our DevSecOps platform. These fea
| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [GitLab Duo Chat](gitlab_duo_chat.md) | **(ULTIMATE BETA)** |
| Assists you in determining the root cause for a pipeline failure and failed CI/CD build. | [Root cause analysis](#root-cause-analysis) | **(ULTIMATE SAAS EXPERIMENT)** |
| Assists you with predicting productivity metrics and identifying anomalies across your software development lifecycle. | [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | **(ULTIMATE ALL EXPERIMENT)** |
| Processes and responds to your questions about your application's usage data. | [Product Analytics](product_analytics/index.md) | **(ULTIMATE SAAS EXPERIMENT)** |
## Enable AI/ML features

View File

@ -24,7 +24,7 @@ Use group-level analytics to get insights into your groups':
- [Security Dashboards](../application_security/security_dashboard/index.md)
- [Contribution analytics](../group/contribution_analytics/index.md)
- [DevOps adoption](../group/devops_adoption/index.md)
- [Insights](../group/insights/index.md)
- [Insights](../project/insights/index.md)
- [Issue analytics](../group/issues_analytics/index.md)
- [Productivity analytics](productivity_analytics.md)
- [Repositories analytics](../group/repositories_analytics/index.md)
@ -40,7 +40,7 @@ Use project-level analytics to get insights into your projects':
- [Code review analytics](code_review_analytics.md)
- [Contributor analytics](../../user/analytics/contributor_analytics.md)
- [Insights](../project/insights/index.md)
- [Issue analytics](../../user/analytics/issue_analytics.md)
- [Issue analytics](../group/issues_analytics/index.md)
- [Merge request analytics](merge_request_analytics.md), enabled with the `project_merge_request_analytics`
[feature flag](../../development/feature_flags/index.md#enabling-a-feature-flag-locally-in-development)
- [Repository analytics](repository_analytics.md)

View File

@ -22,7 +22,7 @@ You can discuss individual custom role and permission requests in [issue 391760]
## Available permissions
For more information on available permissions, see [custom abilities](custom_roles/abilities.md).
For more information on available permissions, see [custom permissions](custom_roles/abilities.md).
## Create a custom role
@ -41,7 +41,7 @@ You create a custom role by selecting [permissions](#available-permissions) to a
to a base role.
You can select any number of permissions. For example, you can create a custom role
with the ability to:
with the permission to:
- View vulnerability reports.
- Change the status of vulnerabilities.

View File

@ -14,14 +14,14 @@ info: "To determine the technical writer assigned to the Stage/Group associated
edit `tooling/custom_roles/docs/templates/custom_abilities.md.erb`.
--->
# Available custom abilities
# Available custom permissions
The following abilities are available. You can add these abilities in any combination
The following permissions are available. You can add these permissions in any combination
to a base role to create a custom role.
Some abilities require having other abilities enabled first. For example, administration of vulnerabilities (`admin_vulnerability`) can only be enabled if reading vulnerabilities (`read_vulnerability`) is also enabled.
Some permissions require having other permissions enabled first. For example, administration of vulnerabilities (`admin_vulnerability`) can only be enabled if reading vulnerabilities (`read_vulnerability`) is also enabled.
These requirements are documented in the `Required ability` column in the following table.
These requirements are documented in the `Required permission` column in the following table.
## Code review workflow

View File

@ -152,6 +152,14 @@ After migration:
If you used a private network on your source instance to hide content from the general public,
make sure to have a similar setup on the destination instance, or to import into a private group.
## Ensure projects can be imported
You cannot import groups with projects when the source instance or group has **Default project creation protection** set to **No one**. If required, this setting can
be changed:
- For [a whole instance](../../../administration/settings/visibility_and_access_controls.md#define-which-roles-can-create-projects).
- For [specific groups](../index.md#specify-who-can-add-projects-to-a-group).
## Prerequisites
> Requirement for Maintainer role instead of Developer role introduced in GitLab 16.0 and backported to GitLab 15.11.1 and GitLab 15.10.5.
@ -217,7 +225,7 @@ role.
1. By default, the proposed group namespaces match the names as they exist in source instance, but based on your permissions, you can choose to edit these names before you proceed to import any of them.
1. Next to the groups you want to import, select either:
- **Import with projects**.
- **Import with projects**. If this is not available, see [Ensure projects can be imported](#ensure-projects-can-be-imported).
- **Import without projects**.
1. The **Status** column shows the import status of each group. If you leave the page open, it updates in real-time.
1. After a group has been imported, select its GitLab path to open its GitLab URL.

View File

@ -16,7 +16,7 @@ Configure insights for your projects and groups to explore data such as:
You can also create custom Insights reports that are relevant for your group.
## View project insights
## View insights
Prerequisites:

View File

@ -133,7 +133,7 @@ If you do not have an existing SSH key pair, generate a new one:
Enter file in which to save the key (/home/user/.ssh/id_ed25519):
```
1. Accept the suggested filename and directory, unless you are generating a [deploy key](project/deploy_keys/index.md)
1. Accept the suggested file name and directory, unless you are generating a [deploy key](project/deploy_keys/index.md)
or want to save in a specific directory where you store other keys.
You can also dedicate the SSH key pair to a [specific host](#configure-ssh-to-point-to-a-different-directory).
@ -254,7 +254,7 @@ To generate ED25519_SK or ECDSA_SK SSH keys, you must use OpenSSH 8.2 or later:
1. Touch the button on the hardware security key.
1. Accept the suggested filename and directory:
1. Accept the suggested file name and directory:
```plaintext
Enter file in which to save the key (/home/user/.ssh/id_ed25519_sk):
@ -279,16 +279,17 @@ A public and private key are generated.
You can use [1Password](https://1password.com/) and the [1Password browser extension](https://support.1password.com/getting-started-browser/) to either:
- Automatically generate a new SSH key.
- Use an existing SSH in your 1Password vault to authenticate with GitLab.
- Use an existing SSH key in your 1Password vault to authenticate with GitLab.
1. Sign in to GitLab.
1. On the left sidebar, select your avatar.
1. Select **Edit profile**.
1. On the left sidebar, select **SSH Keys**.
1. Select **Add new key**.
1. Select **Key**, and you should see the 1Password helper appear.
1. Select the 1Password icon and unlock 1Password.
1. You can then select **Create SSH Key** or select an existing SSH key to fill in the public key.
1. In the **Title** box, type a description, like `Work Laptop` or
1. In the **Title** box, enter a description, like `Work Laptop` or
`Home Workstation`.
1. Optional. Select the **Usage type** of the key. It can be used either for `Authentication` or `Signing` or both. `Authentication & Signing` is the default value.
1. Optional. Update **Expiration date** to modify the default expiration date.
@ -324,7 +325,7 @@ To use SSH with GitLab, copy your public key to your GitLab account:
cat ~/.ssh/id_ed25519.pub | clip
```
Replace `id_ed25519.pub` with your filename. For example, use `id_rsa.pub` for RSA.
Replace `id_ed25519.pub` with your file name. For example, use `id_rsa.pub` for RSA.
1. Sign in to GitLab.
1. On the left sidebar, select your avatar.

View File

@ -17,6 +17,7 @@ module Gitlab
module Redis
class Wrapper
InvalidPathError = Class.new(StandardError)
CommandExecutionError = Class.new(StandardError)
class << self
delegate :params, :url, :store, :encrypted_secrets, :redis_client_params, to: :new
@ -178,17 +179,46 @@ module Gitlab
config[:instrumentation_class] ||= self.class.instrumentation_class
decrypted_config = parse_encrypted_config(config)
final_config = parse_extra_config(decrypted_config)
result = if decrypted_config[:cluster].present?
decrypted_config[:db] = 0 # Redis Cluster only supports db 0
decrypted_config
result = if final_config[:cluster].present?
final_config[:db] = 0 # Redis Cluster only supports db 0
final_config
else
parse_redis_url(decrypted_config)
parse_redis_url(final_config)
end
parse_client_tls_options(result)
end
def parse_extra_config(decrypted_config)
command = decrypted_config.delete(:config_command)
return decrypted_config unless command.present?
config_from_command = extra_config_from_command(command)
return decrypted_config unless config_from_command.present?
decrypted_config.deep_merge(config_from_command)
end
def extra_config_from_command(command)
cmd = command.split(" ")
output, exit_status = Gitlab::Popen.popen(cmd)
if exit_status != 0
raise CommandExecutionError,
"Redis: Execution of `#{command}` failed with exit code #{exit_status}. Output: #{output}"
end
YAML.safe_load(output).deep_symbolize_keys
rescue Psych::SyntaxError => e
error_message = <<~MSG
Redis: Execution of `#{command}` generated invalid yaml.
Error: #{e.problem} #{e.context} at line #{e.line} column #{e.column}
MSG
raise CommandExecutionError, error_message
end
def parse_encrypted_config(encrypted_config)
encrypted_config.delete(:secret_file)

View File

@ -1931,6 +1931,12 @@ msgstr ""
msgid "AIAgents|AI Agents"
msgstr ""
msgid "AIAgents|Agent name"
msgstr ""
msgid "AIAgents|An error has occurred when saving the agent."
msgstr ""
msgid "AIAgents|Create agent"
msgstr ""
@ -9115,6 +9121,9 @@ msgstr ""
msgid "BulkImport|Be aware of %{linkStart}visibility rules%{linkEnd} when importing groups."
msgstr ""
msgid "BulkImport|Because of settings on the source GitLab instance or group, you can't import projects with this group. To permit importing projects with this group, reconfigure the source GitLab instance or group. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "BulkImport|Check that the source instance base URL and the personal access token meet the necessary requirements."
msgstr ""
@ -9226,6 +9235,9 @@ msgstr ""
msgid "BulkImport|Showing %{start}-%{end} of %{total} that you own matching filter \"%{filter}\" from %{link}"
msgstr ""
msgid "BulkImport|Some groups will be imported without projects."
msgstr ""
msgid "BulkImport|Source"
msgstr ""
@ -20198,6 +20210,9 @@ msgstr ""
msgid "Expand milestones"
msgstr ""
msgid "Expand project information"
msgstr ""
msgid "Expand settings section"
msgstr ""
@ -39412,6 +39427,9 @@ msgstr ""
msgid "Promotions|You can restrict access to protected branches by choosing a role (Maintainers, Developers) as well as certain users."
msgstr ""
msgid "Prompt"
msgstr ""
msgid "Prompt users to upload SSH keys"
msgstr ""
@ -43879,6 +43897,12 @@ msgstr ""
msgid "SecurityOrchestration|Add new approver"
msgstr ""
msgid "SecurityOrchestration|Add protected branches"
msgstr ""
msgid "SecurityOrchestration|Add regular branches"
msgstr ""
msgid "SecurityOrchestration|Add rule"
msgstr ""

View File

@ -2,7 +2,11 @@
module QA
RSpec.describe 'Create' do
describe 'Repository tags', :reliable, product_group: :source_code do
describe 'Repository tags', :reliable, product_group: :source_code, quarantine: {
type: :flaky,
issue: "https://gitlab.com/gitlab-org/gitlab/-/issues/438349",
only: { job: 'gdk-qa-reliable' }
} do
let(:project) { create(:project, :with_readme, name: 'project-for-tags') }
let(:developer_user) do
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)

View File

@ -0,0 +1,4 @@
development:
config_command: '/opt/redis-config.sh'
url: 'redis://redis.example.com'
password: 'dummy-password'

View File

@ -1,124 +1,147 @@
import {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
GlButtonGroup,
GlButton,
GlIcon,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
describe('import actions cell', () => {
let wrapper;
const defaultProps = {
isFinished: false,
isAvailableForImport: false,
isInvalid: false,
isProjectCreationAllowed: true,
};
const createComponent = (props) => {
wrapper = shallowMount(ImportActionsCell, {
wrapper = shallowMountExtended(ImportActionsCell, {
propsData: {
isFinished: false,
isAvailableForImport: false,
isInvalid: false,
...defaultProps,
...props,
},
stubs: {
GlButtonGroup,
GlDisclosureDropdown,
GlDisclosureDropdownItem,
},
});
};
describe('when group is available for import', () => {
beforeEach(() => {
createComponent({ isAvailableForImport: true });
});
const findButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findDropdownItem = () => findDropdown().findComponent(GlDisclosureDropdownItem);
const findReimportInfoIcon = () => wrapper.findByTestId('reimport-info-icon');
const findProjectCreationWarningIcon = () =>
wrapper.findByTestId('project-creation-warning-icon');
it('renders import dropdown', () => {
const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Import with projects');
});
describe.each`
isProjectCreationAllowed | isAvailableForImport | isFinished | expectedButton | expectedDropdown | expectedWarningIcon
${true} | ${false} | ${false} | ${false} | ${false} | ${false}
${true} | ${false} | ${true} | ${'Re-import with projects'} | ${'Re-import without projects'} | ${false}
${true} | ${true} | ${false} | ${'Import with projects'} | ${'Import without projects'} | ${false}
${true} | ${true} | ${true} | ${'Re-import with projects'} | ${'Re-import without projects'} | ${false}
${false} | ${false} | ${false} | ${false} | ${false} | ${false}
${false} | ${false} | ${true} | ${'Re-import without projects'} | ${false} | ${true}
${false} | ${true} | ${false} | ${'Import without projects'} | ${false} | ${true}
${false} | ${true} | ${true} | ${'Re-import without projects'} | ${false} | ${true}
`(
'isProjectCreationAllowed = $isProjectCreationAllowed, isAvailableForImport = $isAvailableForImport, isFinished = $isFinished',
({
isAvailableForImport,
isFinished,
isProjectCreationAllowed,
expectedButton,
expectedDropdown,
expectedWarningIcon,
}) => {
beforeEach(() => {
createComponent({ isAvailableForImport, isFinished, isProjectCreationAllowed });
});
it('does not render icon with a hint', () => {
expect(wrapper.findComponent(GlIcon).exists()).toBe(false);
});
});
if (expectedButton) {
it(`renders button with "${expectedButton}" text`, () => {
const button = findButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe(expectedButton);
});
} else {
it('does not render button', () => {
expect(findButton().exists()).toBe(false);
});
}
describe('when group is finished', () => {
beforeEach(() => {
createComponent({ isAvailableForImport: false, isFinished: true });
});
if (expectedDropdown) {
it(`renders dropdown with "${expectedDropdown}" text`, () => {
expect(findDropdown().exists()).toBe(true);
expect(findDropdownItem().text()).toBe(expectedDropdown);
});
} else {
it('does not render dropdown', () => {
expect(findDropdown().exists()).toBe(false);
});
}
it('renders re-import dropdown', () => {
const button = wrapper.findComponent(GlButton);
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Re-import with projects');
});
if (isFinished) {
it('renders re-import info icon with a hint', () => {
const icon = findReimportInfoIcon();
expect(icon.exists()).toBe(true);
expect(icon.attributes()).toMatchObject({
name: 'information-o',
title: 'Re-import creates a new group. It does not sync with the existing group.',
});
});
} else {
it('does not render re-import info icon', () => {
expect(findReimportInfoIcon().exists()).toBe(false);
});
}
it('renders icon with a hint', () => {
const icon = wrapper.findComponent(GlIcon);
expect(icon.exists()).toBe(true);
expect(icon.attributes().title).toBe(
'Re-import creates a new group. It does not sync with the existing group.',
);
});
});
it('does not render import dropdown when group is not available for import', () => {
createComponent({ isAvailableForImport: false });
const dropdown = wrapper.findComponent(GlDisclosureDropdown);
expect(dropdown.exists()).toBe(false);
});
if (expectedWarningIcon) {
it('renders project creation warning icon', () => {
const icon = findProjectCreationWarningIcon();
expect(icon.exists()).toBe(true);
expect(icon.attributes('name')).toBe('warning');
});
} else {
it('does not render project creation warning icon', () => {
expect(findProjectCreationWarningIcon().exists()).toBe(false);
});
}
},
);
it('renders import dropdown as disabled when group is invalid', () => {
createComponent({ isInvalid: true, isAvailableForImport: true });
const dropdown = wrapper.findComponent(GlDisclosureDropdown);
expect(dropdown.props().disabled).toBe(true);
expect(findDropdown().props().disabled).toBe(true);
});
it('emits import-group event when import button is clicked', () => {
it('emits import-group event (with projects) when import button is clicked', () => {
createComponent({ isAvailableForImport: true });
const button = wrapper.findComponent(GlButton);
button.vm.$emit('click');
findButton().vm.$emit('click');
expect(wrapper.emitted('import-group')).toHaveLength(1);
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: true }]);
});
describe.each`
isFinished | expectedAction
${false} | ${'Import'}
${true} | ${'Re-import'}
`(
'group is available for import and finish status is $isFinished',
({ isFinished, expectedAction }) => {
beforeEach(() => {
createComponent({ isAvailableForImport: true, isFinished });
});
it('emits import-group event (without projects) when dropdown option is clicked', () => {
createComponent({ isAvailableForImport: true });
it('render import dropdown', () => {
const button = wrapper.findComponent(GlButton);
const dropdown = wrapper.findComponent(GlDisclosureDropdown);
expect(button.element).toHaveText(`${expectedAction} with projects`);
expect(dropdown.findComponent(GlDisclosureDropdownItem).text()).toBe(
`${expectedAction} without projects`,
);
});
findDropdownItem().vm.$emit('action');
it('request migrate projects by default', () => {
const button = wrapper.findComponent(GlButton);
button.vm.$emit('click');
expect(wrapper.emitted('import-group')).toHaveLength(1);
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: false }]);
});
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: true }]);
});
it('emits import-group event (without projects) when isProjectCreationAllowed is false and import button is clicked', () => {
createComponent({
isProjectCreationAllowed: false,
isAvailableForImport: true,
});
it('request not to migrate projects via dropdown option', () => {
const dropdown = wrapper.findComponent(GlDisclosureDropdown);
dropdown.findComponent(GlDisclosureDropdownItem).vm.$emit('action');
findButton().vm.$emit('click');
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: false }]);
});
},
);
expect(wrapper.emitted('import-group')).toHaveLength(1);
expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: false }]);
});
});

View File

@ -68,6 +68,7 @@ describe('import table', () => {
const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]');
const findNewPathCol = () => wrapper.find('[data-test-id="new-path-col"]');
const findUnavailableFeaturesWarning = () => wrapper.findByTestId('unavailable-features-alert');
const findImportProjectsWarning = () => wrapper.findByTestId('import-projects-warning');
const findAllImportStatuses = () => wrapper.findAllComponents(ImportStatus);
const triggerSelectAllCheckbox = (checked = true) =>
@ -628,6 +629,50 @@ describe('import table', () => {
expect(findImportSelectedDropdown().props().disabled).toBe(false);
});
it('does not render import projects warning when target with isProjectCreationAllowed = true is selected', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
await selectRow(0);
await nextTick();
expect(findImportProjectsWarning().exists()).toBe(false);
});
it('renders import projects warning when target with isProjectCreationAllowed = false is selected', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: [
{
...generateFakeEntry({ id: 1, status: STATUSES.NONE }),
lastImportTarget: {
id: 1,
targetNamespace: AVAILABLE_NAMESPACES[2].fullPath,
newName: 'does-not-matter',
},
},
],
pageInfo: FAKE_PAGE_INFO,
versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
await selectRow(0);
await nextTick();
expect(findImportProjectsWarning().props('name')).toBe('warning');
expect(findImportProjectsWarning().attributes('title')).toBe(
'Some groups will be imported without projects.',
);
});
it('does not allow selecting already started groups', async () => {
const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.STARTED })];

View File

@ -60,10 +60,11 @@ export const statusEndpointFixture = {
},
};
const makeGroupMock = ({ id, fullPath }) => ({
const makeGroupMock = ({ id, fullPath, projectCreationLevel = null }) => ({
id,
fullPath,
name: fullPath,
projectCreationLevel: projectCreationLevel || 'maintainer',
visibility: 'public',
webUrl: `http://gdk.test:3000/groups/${fullPath}`,
__typename: 'Group',
@ -72,8 +73,8 @@ const makeGroupMock = ({ id, fullPath }) => ({
export const AVAILABLE_NAMESPACES = [
makeGroupMock({ id: 24, fullPath: 'Commit451' }),
makeGroupMock({ id: 22, fullPath: 'gitlab-org' }),
makeGroupMock({ id: 23, fullPath: 'gnuwget' }),
makeGroupMock({ id: 25, fullPath: 'jashkenas' }),
makeGroupMock({ id: 23, fullPath: 'gnuwget', projectCreationLevel: 'noone' }),
makeGroupMock({ id: 25, fullPath: 'jashkenas', projectCreationLevel: 'developer' }),
];
export const availableNamespacesFixture = {

View File

@ -1,5 +1,9 @@
import { STATUSES } from '~/import_entities/constants';
import { isFinished, isAvailableForImport } from '~/import_entities/import_groups/utils';
import {
isFinished,
isAvailableForImport,
isProjectCreationAllowed,
} from '~/import_entities/import_groups/utils';
const FINISHED_STATUSES = [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT];
const OTHER_STATUSES = Object.values(STATUSES).filter(
@ -53,4 +57,19 @@ describe('Direct transfer status utils', () => {
expect(isFinished({ progress: { status: 'weird' } })).toBe(false);
});
});
describe('isProjectCreationAllowed', () => {
it.each`
projectCreationLevel | expected
${null} | ${false}
${'noone'} | ${false}
${'developer'} | ${true}
${'maintainer'} | ${true}
`(
'when projectCreationLevel is $projectCreationLevel, returns $expected',
({ projectCreationLevel, expected }) => {
expect(isProjectCreationAllowed({ projectCreationLevel })).toBe(expected);
},
);
});
});

View File

@ -2,6 +2,7 @@ const mockGroupFactory = (fullPath) => ({
id: `gid://gitlab/Group/${fullPath}`,
fullPath,
name: fullPath,
projectCreationLevel: 'maintainer',
visibility: 'public',
webUrl: `http://gdk.test:3000/groups/${fullPath}`,
__typename: 'Group',

View File

@ -2,6 +2,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initReadMore from '~/read_more';
describe('Read more click-to-expand functionality', () => {
const findTarget = () => document.querySelector('.read-more-container');
const findTrigger = () => document.querySelector('.js-read-more-trigger');
afterEach(() => {
@ -19,13 +20,11 @@ describe('Read more click-to-expand functionality', () => {
});
it('adds "is-expanded" class to target element', () => {
const target = document.querySelector('.read-more-container');
const trigger = findTrigger();
initReadMore();
trigger.click();
findTrigger().click();
expect(target.classList.contains('is-expanded')).toEqual(true);
expect(findTarget().classList.contains('is-expanded')).toEqual(true);
});
});
@ -38,8 +37,7 @@ describe('Read more click-to-expand functionality', () => {
</button>
`);
const trigger = findTrigger();
const nestedElement = trigger.firstElementChild;
const nestedElement = findTrigger().firstElementChild;
initReadMore();
nestedElement.click();
@ -49,4 +47,41 @@ describe('Read more click-to-expand functionality', () => {
expect(findTrigger()).toBe(null);
});
});
describe('data-read-more-height defines when to show the read-more button', () => {
afterEach(() => {
resetHTMLFixture();
});
it('if not set shows button all the time', () => {
setHTMLFixture(`
<div class="read-more-container">
<p class="read-more-content">Occaecat voluptate exercitation aliqua et duis eiusmod mollit esse ea laborum amet consectetur officia culpa anim. Fugiat laboris eu irure deserunt excepteur laboris irure quis. Occaecat nostrud irure do officia ea laborum velit sunt. Aliqua incididunt non deserunt proident magna aliqua sunt laborum laborum eiusmod ullamco. Et elit commodo irure. Labore eu nisi proident.</p>
<button type="button" class="js-read-more-trigger">
Button text
</button>
</div>
`);
initReadMore();
expect(findTrigger()).not.toBe(null);
});
it('if set hides button as threshold is met', () => {
setHTMLFixture(`
<div class="read-more-container" data-read-more-height="120">
<p class="read-more-content read-more-content--has-scrim">Occaecat voluptate exercitation aliqua et duis eiusmod mollit esse ea laborum amet consectetur officia culpa anim. Fugiat laboris eu irure deserunt excepteur laboris irure quis. Occaecat nostrud irure do officia ea laborum velit sunt. Aliqua incididunt non deserunt proident magna aliqua sunt laborum laborum eiusmod ullamco. Et elit commodo irure. Labore eu nisi proident.</p>
<button type="button" class="js-read-more-trigger">
Button text
</button>
</div>
`);
initReadMore();
expect(findTarget().classList.contains('read-more-content--has-scrim')).toBe(false);
expect(findTrigger()).toBe(null);
});
});
});

View File

@ -41,6 +41,11 @@ RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do
it { is_expected.to define_enum_for(:state).with_values({ draft: 0, published: 1 }) }
it do
is_expected.to define_enum_for(:verification_level)
.with_values({ unverified: 0, gitlab: 1 })
end
describe '.for_projects' do
it 'returns catalog resources for the given project IDs' do
resources_for_projects = described_class.for_projects(project_a.id)

View File

@ -32,8 +32,6 @@
- ee/spec/features/groups/analytics/productivity_analytics_spec.rb
- ee/spec/features/groups/member_roles_spec.rb
- ee/spec/features/groups/members/list_members_spec.rb
- ee/spec/features/groups/security/policies_list_spec.rb
- ee/spec/features/groups/security/policy_editor_spec.rb
- ee/spec/features/groups/usage_quotas/code_suggestions_usage_tab_spec.rb
- ee/spec/features/groups/wikis_spec.rb
- ee/spec/features/incidents/incident_details_spec.rb
@ -89,8 +87,6 @@
- ee/spec/features/projects/new_project_spec.rb
- ee/spec/features/projects/path_locks_spec.rb
- ee/spec/features/projects/push_rules_spec.rb
- ee/spec/features/projects/security/policies_list_spec.rb
- ee/spec/features/projects/security/policy_editor_spec.rb
- ee/spec/features/projects/settings/ee/repository_mirrors_settings_spec.rb
- ee/spec/features/projects/settings/merge_requests/user_manages_merge_requests_template_spec.rb
- ee/spec/features/projects/settings/packages_spec.rb

View File

@ -161,6 +161,42 @@ RSpec.shared_examples "redis_shared_examples" do
expect(params2).not_to have_key(:foo)
end
context 'with command to generate extra config specified' do
let(:config_file_name) { 'spec/fixtures/config/redis_config_with_extra_config_command.yml' }
context 'when the command returns valid yaml' do
before do
allow(Gitlab::Popen).to receive(:popen).and_return(["password: 'actual-password'\n", 0])
end
it 'merges config from command on top of config from file' do
is_expected.to include(password: 'actual-password')
end
end
context 'when the command returns invalid yaml' do
before do
allow(Gitlab::Popen).to receive(:popen).and_return(["password: 'actual-password\n", 0])
end
it 'raises error' do
expect { subject }.to raise_error(Gitlab::Redis::Wrapper::CommandExecutionError,
%r{Redis: Execution of `/opt/redis-config.sh` generated invalid yaml})
end
end
context 'when the command fails' do
before do
allow(Gitlab::Popen).to receive(:popen).and_return(["", 125])
end
it 'raises error' do
expect { subject }.to raise_error(Gitlab::Redis::Wrapper::CommandExecutionError,
%r{Redis: Execution of `/opt/redis-config.sh` failed})
end
end
end
context 'when url contains unix socket reference' do
context 'with old format' do
let(:config_file_name) { config_old_format_socket }

View File

@ -34,14 +34,14 @@ info: "To determine the technical writer assigned to the Stage/Group associated
edit `tooling/custom_roles/docs/templates/custom_abilities.md.erb`.
--->
# Available custom abilities
# Available custom permissions
The following abilities are available. You can add these abilities in any combination
The following permissions are available. You can add these permissions in any combination
to a base role to create a custom role.
Some abilities require having other abilities enabled first. For example, administration of vulnerabilities (`admin_vulnerability`) can only be enabled if reading vulnerabilities (`read_vulnerability`) is also enabled.
Some permissions require having other permissions enabled first. For example, administration of vulnerabilities (`admin_vulnerability`) can only be enabled if reading vulnerabilities (`read_vulnerability`) is also enabled.
These requirements are documented in the `Required ability` column in the following table.
These requirements are documented in the `Required permission` column in the following table.
<% custom_abilities_by_feature_category.sort.each do |category, abilities| %>
## <%= "#{humanize(category)}" %>