summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-11-14 15:11:29 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-11-14 15:11:29 +0000
commit2d80ade70258fa78e9ada2e8b3055129a69654f3 (patch)
treea23e474b4cc5184db445a6121f9eb75c0653fe5f
parent61a82b8ec062d6f122dadd38783c7754cef7ce2b (diff)
downloadgitlab-ce-2d80ade70258fa78e9ada2e8b3055129a69654f3.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/review-apps/main.gitlab-ci.yml2
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js7
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue35
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue7
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue138
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue132
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue159
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue226
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/settings.js60
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js27
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue6
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue21
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue18
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue42
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue37
-rw-r--r--app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/milestone.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql17
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql7
-rw-r--r--app/controllers/concerns/access_tokens_actions.rb33
-rw-r--r--app/controllers/concerns/render_access_tokens.rb31
-rw-r--r--app/controllers/groups/settings/access_tokens_controller.rb1
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb33
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb1
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb2
-rw-r--r--app/graphql/types/commit_type.rb2
-rw-r--r--app/graphql/types/project_type.rb2
-rw-r--r--app/graphql/types/projects/branch_rule_type.rb12
-rw-r--r--app/graphql/types/release_links_type.rb10
-rw-r--r--app/graphql/types/release_source_type.rb2
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/repository_type.rb2
-rw-r--r--app/models/ci/build.rb14
-rw-r--r--app/models/concerns/file_store_mounter.rb34
-rw-r--r--app/models/concerns/packages/debian/distribution.rb9
-rw-r--r--app/models/project_setting.rb1
-rw-r--r--app/presenters/release_presenter.rb10
-rw-r--r--app/services/users/destroy_service.rb42
-rw-r--r--app/views/groups/settings/_remove.html.haml2
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml3
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--config/feature_flags/development/ci_assign_job_token_on_scheduling.yml8
-rw-r--r--config/initializers/1_settings.rb2
-rw-r--r--config/open_api.yml4
-rw-r--r--db/migrate/20221102225800_add_max_seats_used_changed_at_index_to_gitlab_subscriptions.rb15
-rw-r--r--db/post_migrate/20221108121322_add_supporting_index_for_vulnerabilities_feedback_migration.rb24
-rw-r--r--db/schema_migrations/202211022258001
-rw-r--r--db/schema_migrations/202211081213221
-rw-r--r--db/structure.sql4
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md2
-rw-r--r--doc/administration/reference_architectures/10k_users.md2
-rw-r--r--doc/api/graphql/reference/index.md43
-rw-r--r--doc/api/suggestions.md54
-rw-r--r--doc/development/testing_guide/end_to_end/feature_flags.md2
-rw-r--r--doc/development/utilities.md16
-rw-r--r--doc/integration/omniauth.md66
-rw-r--r--doc/update/index.md4
-rw-r--r--doc/user/admin_area/license.md2
-rw-r--r--doc/user/analytics/dora_metrics.md12
-rw-r--r--doc/user/project/merge_requests/changes.md6
-rw-r--r--doc/user/project/merge_requests/img/conflict_ui_v14_0.pngbin8371 -> 0 bytes
-rw-r--r--doc/user/project/merge_requests/img/conflict_ui_v15_6.pngbin0 -> 13672 bytes
-rw-r--r--doc/user/project/repository/img/web_editor_markdown_live_preview.pngbin0 -> 454769 bytes
-rw-r--r--doc/user/project/repository/web_editor.md14
-rw-r--r--doc/user/tasks.md24
-rw-r--r--lib/api/api.rb4
-rw-r--r--lib/api/dependency_proxy.rb13
-rw-r--r--lib/api/entities/resource_milestone_event.rb12
-rw-r--r--lib/api/resource_milestone_events.rb17
-rw-r--r--lib/api/terraform/modules/v1/packages.rb2
-rw-r--r--lib/api/terraform/state_version.rb2
-rw-r--r--locale/gitlab.pot31
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb3
-rwxr-xr-xscripts/review_apps/review-apps.sh20
-rw-r--r--scripts/utils.sh2
-rw-r--r--spec/controllers/profiles/personal_access_tokens_controller_spec.rb95
-rw-r--r--spec/factories/ci/builds.rb12
-rw-r--r--spec/frontend/ci/runner/components/runner_bulk_delete_spec.js136
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_button_spec.js11
-rw-r--r--spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js174
-rw-r--r--spec/frontend/ci_variable_list/components/ci_group_variables_spec.js180
-rw-r--r--spec/frontend/ci_variable_list/components/ci_project_variables_spec.js201
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js426
-rw-r--r--spec/frontend/ci_variable_list/mocks.js77
-rw-r--r--spec/frontend/token_access/mock_data.js12
-rw-r--r--spec/frontend/token_access/token_access_spec.js7
-rw-r--r--spec/frontend/token_access/token_projects_table_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/help_popover_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js3
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js52
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js7
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_spec.js28
-rw-r--r--spec/frontend/work_items/mock_data.js30
-rw-r--r--spec/frontend/work_items/router_spec.js13
-rw-r--r--spec/graphql/types/commit_type_spec.rb2
-rw-r--r--spec/graphql/types/projects/branch_rule_type_spec.rb1
-rw-r--r--spec/graphql/types/release_links_type_spec.rb10
-rw-r--r--spec/graphql/types/release_source_type_spec.rb2
-rw-r--r--spec/graphql/types/repository_type_spec.rb2
-rw-r--r--spec/models/ci/build_spec.rb22
-rw-r--r--spec/models/concerns/file_store_mounter_spec.rb93
-rw-r--r--spec/models/concerns/has_user_type_spec.rb42
-rw-r--r--spec/models/concerns/token_authenticatable_spec.rb10
-rw-r--r--spec/models/packages/package_file_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/branch_rules_spec.rb144
-rw-r--r--spec/requests/groups/settings/access_tokens_controller_spec.rb13
-rw-r--r--spec/requests/projects/settings/access_tokens_controller_spec.rb13
-rw-r--r--spec/services/users/destroy_service_spec.rb40
-rw-r--r--spec/support/shared_examples/features/access_tokens_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb2
-rw-r--r--spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb41
-rw-r--r--test.html439
134 files changed, 2231 insertions, 1889 deletions
diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml
index 68fc4e48ba9..8b49327b9f4 100644
--- a/.gitlab/ci/review-apps/main.gitlab-ci.yml
+++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml
@@ -123,7 +123,7 @@ review-deploy:
- run_timed_command "check_kube_domain"
- run_timed_command "download_chart"
- run_timed_command "deploy" || (display_deployment_debug && exit 1)
- - run_timed_command "verify_deploy" || exit 1
+ - run_timed_command "verify_deploy"|| (display_deployment_debug && exit 1)
- run_timed_command "disable_sign_ups" || (delete_release && exit 1)
after_script:
# Run seed-dast-test-data.sh only when DAST_RUN is set to true. This is to pupulate review app with data for DAST scan.
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index c3191271fd8..f76ff29f1bb 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-a415ff702cfd0755db5d1a09c63c13ce13b54f58
+4b3f2921b5f0d659b44aee6323d82fc3698a8ede
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 3239375bf7c..38ee02938cc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -93,6 +93,12 @@ export const GO_TO_YOUR_MERGE_REQUESTS = {
defaultKeys: ['shift+m'],
};
+export const GO_TO_YOUR_REVIEW_REQUESTS = {
+ id: 'globalShortcuts.goToYourReviewRequests',
+ description: __('Go to your review requests'),
+ defaultKeys: ['shift+r'],
+};
+
export const GO_TO_YOUR_TODO_LIST = {
id: 'globalShortcuts.goToYourTodoList',
description: __('Go to your To-Do list'),
@@ -523,6 +529,7 @@ export const GLOBAL_SHORTCUTS_GROUP = {
FOCUS_FILTER_BAR,
GO_TO_YOUR_ISSUES,
GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_REVIEW_REQUESTS,
GO_TO_YOUR_TODO_LIST,
TOGGLE_PERFORMANCE_BAR,
HIDE_APPEARING_CONTENT,
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 4d78c7b56a0..7a1577e97d5 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -24,6 +24,7 @@ import {
GO_TO_MILESTONE_LIST,
GO_TO_YOUR_SNIPPETS,
GO_TO_PROJECT_FIND_FILE,
+ GO_TO_YOUR_REVIEW_REQUESTS,
} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
@@ -94,6 +95,9 @@ export default class Shortcuts {
Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
findAndFollowLink('.dashboard-shortcuts-merge_requests'),
);
+ Mousetrap.bind(keysFor(GO_TO_YOUR_REVIEW_REQUESTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-review_requests'),
+ );
Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
findAndFollowLink('.dashboard-shortcuts-projects'),
);
diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
index 703da01d9c8..1ec3f8da7c3 100644
--- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -117,31 +117,34 @@ export default {
const { errors, deletedIds } = data.bulkRunnerDelete;
if (errors?.length) {
- this.onError(new Error(errors.join(' ')));
- this.$refs.modal.hide();
- return;
+ createAlert({
+ message: s__(
+ 'Runners|An error occurred while deleting. Some runners may not have been deleted.',
+ ),
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
}
- this.$emit('deleted', {
- message: this.toastConfirmationMessage(deletedIds.length),
- });
+ if (deletedIds?.length) {
+ this.$emit('deleted', {
+ message: this.toastConfirmationMessage(deletedIds.length),
+ });
- // Clean up
-
- // Remove deleted runners from the cache
- deletedIds.forEach((id) => {
- const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
- cache.evict({ id: cacheId });
- });
- cache.gc();
-
- this.$refs.modal.hide();
+ // Remove deleted runners from the cache
+ deletedIds.forEach((id) => {
+ const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+ cache.evict({ id: cacheId });
+ });
+ cache.gc();
+ }
},
});
} catch (error) {
this.onError(error);
} finally {
this.isDeleting = false;
+ this.$refs.modal.hide();
}
},
onError(error) {
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index 13404baad89..32d4076b00f 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -2,7 +2,7 @@
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
import { createAlert } from '~/flash';
-import { sprintf } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
@@ -122,8 +122,11 @@ export default {
onError(error) {
this.deleting = false;
const { message } = error;
+ const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), {
+ runnerName: this.runnerName,
+ });
- createAlert({ message });
+ createAlert({ title, message });
captureException({ error, component: this.$options.name });
},
},
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
index 8d891ff1746..afdac28cbd6 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -1,143 +1,35 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
-import { reportMessageToSentry } from '../utils';
+import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
import getAdminVariables from '../graphql/queries/variables.query.graphql';
-import {
- ADD_MUTATION_ACTION,
- DELETE_MUTATION_ACTION,
- UPDATE_MUTATION_ACTION,
- genericMutationErrorText,
- variableFetchErrorText,
-} from '../constants';
import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql';
import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql';
import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
+ CiVariableShared,
},
- inject: ['endpoint'],
- data() {
- return {
- adminVariables: [],
- hasNextPage: false,
- isInitialLoading: true,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- };
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addAdminVariable,
+ [UPDATE_MUTATION_ACTION]: updateAdminVariable,
+ [DELETE_MUTATION_ACTION]: deleteAdminVariable,
},
- apollo: {
- adminVariables: {
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.ciVariables,
query: getAdminVariables,
- update(data) {
- return data?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.ciVariables?.pageInfo || this.pageInfo;
- this.hasNextPage = this.pageInfo?.hasNextPage || false;
-
- // Because graphQL has a limit of 100 items,
- // we batch load all the variables by making successive queries
- // to keep the same UX. As a safeguard, we make sure that we cannot go over
- // 20 consecutive API calls, which means 2000 variables loaded maximum.
- if (!this.hasNextPage) {
- this.isLoadingMoreItems = false;
- } else if (this.loadingCounter < 20) {
- this.hasNextPage = false;
- this.fetchMoreVariables();
- this.loadingCounter += 1;
- } else {
- createAlert({ message: this.$options.tooManyCallsError });
- reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- watchLoading(flag) {
- if (!flag) {
- this.isInitialLoading = false;
- }
- },
- },
- },
- computed: {
- isLoading() {
- return (
- (this.$apollo.queries.adminVariables.loading && this.isInitialLoading) ||
- this.isLoadingMoreItems
- );
},
},
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.adminVariables.fetchMore({
- variables: {
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
- },
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- variable,
- },
- });
-
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- } else {
- // The writing to cache for admin variable is not working
- // because there is no ID in the cache at the top level.
- // We therefore need to manually refetch.
- this.$apollo.queries.adminVariables.refetch();
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
- },
- },
- componentName: 'InstanceVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
- mutationData: {
- [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteAdminVariable, name: 'deleteAdminVariable' },
- },
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
:are-scoped-variables-available="false"
- :is-loading="isLoading"
- :variables="adminVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="InstanceVariables"
+ :mutation-data="$options.mutationData"
+ :refetch-after-mutation="true"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
index 4af696b8dab..c8f5ac1736d 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -1,143 +1,53 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { reportMessageToSentry } from '../utils';
-import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
GRAPHQL_GROUP_TYPE,
UPDATE_MUTATION_ACTION,
- genericMutationErrorText,
- variableFetchErrorText,
} from '../constants';
+import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
+ CiVariableShared,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['endpoint', 'groupPath', 'groupId'],
- data() {
- return {
- groupVariables: [],
- hasNextPage: false,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- };
- },
- apollo: {
- groupVariables: {
- query: getGroupVariables,
- variables() {
- return {
- fullPath: this.groupPath,
- };
- },
- update(data) {
- return data?.group?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.group?.ciVariables?.pageInfo || this.pageInfo;
- this.hasNextPage = this.pageInfo?.hasNextPage || false;
- // Because graphQL has a limit of 100 items,
- // we batch load all the variables by making successive queries
- // to keep the same UX. As a safeguard, we make sure that we cannot go over
- // 20 consecutive API calls, which means 2000 variables loaded maximum.
- if (!this.hasNextPage) {
- this.isLoadingMoreItems = false;
- } else if (this.loadingCounter < 20) {
- this.hasNextPage = false;
- this.fetchMoreVariables();
- this.loadingCounter += 1;
- } else {
- createAlert({ message: this.$options.tooManyCallsError });
- reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- },
- },
+ inject: ['groupPath', 'groupId'],
computed: {
areScopedVariablesAvailable() {
return this.glFeatures.groupScopedCiVariables;
},
- isLoading() {
- return this.$apollo.queries.groupVariables.loading || this.isLoadingMoreItems;
- },
- },
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.groupVariables.fetchMore({
- variables: {
- fullPath: this.groupPath,
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
- },
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- fullPath: this.groupPath,
- groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId),
- variable,
- },
- });
-
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
+ graphqlId() {
+ return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId);
},
},
- componentName: 'GroupVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
mutationData: {
- [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' },
+ [ADD_MUTATION_ACTION]: addGroupVariable,
+ [UPDATE_MUTATION_ACTION]: updateGroupVariable,
+ [DELETE_MUTATION_ACTION]: deleteGroupVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.group?.ciVariables,
+ query: getGroupVariables,
+ },
},
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
+ :id="graphqlId"
:are-scoped-variables-available="areScopedVariablesAvailable"
- :is-loading="isLoading"
- :variables="groupVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="GroupVariables"
+ :full-path="groupPath"
+ :mutation-data="$options.mutationData"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
index 6bd549817f8..2c4818e20c1 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
@@ -1,160 +1,55 @@
<script>
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
-import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
-import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
GRAPHQL_PROJECT_TYPE,
UPDATE_MUTATION_ACTION,
- environmentFetchErrorText,
- genericMutationErrorText,
- variableFetchErrorText,
} from '../constants';
+import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql';
import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql';
-import CiVariableSettings from './ci_variable_settings.vue';
+import CiVariableShared from './ci_variable_shared.vue';
export default {
components: {
- CiVariableSettings,
- },
- inject: ['endpoint', 'projectFullPath', 'projectId'],
- data() {
- return {
- hasNextPage: false,
- isLoadingMoreItems: false,
- loadingCounter: 0,
- pageInfo: {},
- projectEnvironments: [],
- projectVariables: [],
- };
- },
- apollo: {
- projectEnvironments: {
- query: getProjectEnvironments,
- variables() {
- return {
- fullPath: this.projectFullPath,
- };
- },
- update(data) {
- return mapEnvironmentNames(data?.project?.environments?.nodes);
- },
- error() {
- createAlert({ message: environmentFetchErrorText });
- },
- },
- projectVariables: {
- query: getProjectVariables,
- variables() {
- return {
- after: null,
- fullPath: this.projectFullPath,
- };
- },
- update(data) {
- return data?.project?.ciVariables?.nodes || [];
- },
- result({ data }) {
- this.pageInfo = data?.project?.ciVariables?.pageInfo || this.pageInfo;
- this.hasNextPage = this.pageInfo?.hasNextPage || false;
- // Because graphQL has a limit of 100 items,
- // we batch load all the variables by making successive queries
- // to keep the same UX. As a safeguard, we make sure that we cannot go over
- // 20 consecutive API calls, which means 2000 variables loaded maximum.
- if (!this.hasNextPage) {
- this.isLoadingMoreItems = false;
- } else if (this.loadingCounter < 20) {
- this.hasNextPage = false;
- this.fetchMoreVariables();
- this.loadingCounter += 1;
- } else {
- createAlert({ message: this.$options.tooManyCallsError });
- reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {});
- }
- },
- error() {
- this.isLoadingMoreItems = false;
- this.hasNextPage = false;
- createAlert({ message: variableFetchErrorText });
- },
- },
+ CiVariableShared,
},
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectFullPath', 'projectId'],
computed: {
- isLoading() {
- return (
- this.$apollo.queries.projectVariables.loading ||
- this.$apollo.queries.projectEnvironments.loading ||
- this.isLoadingMoreItems
- );
+ graphqlId() {
+ return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId);
},
},
- methods: {
- addVariable(variable) {
- this.variableMutation(ADD_MUTATION_ACTION, variable);
- },
- deleteVariable(variable) {
- this.variableMutation(DELETE_MUTATION_ACTION, variable);
- },
- fetchMoreVariables() {
- this.isLoadingMoreItems = true;
-
- this.$apollo.queries.projectVariables.fetchMore({
- variables: {
- fullPath: this.projectFullPath,
- after: this.pageInfo.endCursor,
- },
- });
- },
- updateVariable(variable) {
- this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addProjectVariable,
+ [UPDATE_MUTATION_ACTION]: updateProjectVariable,
+ [DELETE_MUTATION_ACTION]: deleteProjectVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.project?.ciVariables,
+ query: getProjectVariables,
},
- async variableMutation(mutationAction, variable) {
- try {
- const currentMutation = this.$options.mutationData[mutationAction];
- const { data } = await this.$apollo.mutate({
- mutation: currentMutation.action,
- variables: {
- endpoint: this.endpoint,
- fullPath: this.projectFullPath,
- projectId: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId),
- variable,
- },
- });
- if (data[currentMutation.name]?.errors?.length) {
- const { errors } = data[currentMutation.name];
- createAlert({ message: errors[0] });
- }
- } catch {
- createAlert({ message: genericMutationErrorText });
- }
+ environments: {
+ lookup: (data) => data?.project?.environments,
+ query: getProjectEnvironments,
},
},
- componentName: 'ProjectVariables',
- i18n: {
- tooManyCallsError: __('Maximum number of variables loaded (2000)'),
- },
- mutationData: {
- [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' },
- [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' },
- [DELETE_MUTATION_ACTION]: { action: deleteProjectVariable, name: 'deleteProjectVariable' },
- },
};
</script>
<template>
- <ci-variable-settings
+ <ci-variable-shared
+ :id="graphqlId"
:are-scoped-variables-available="true"
- :environments="projectEnvironments"
- :is-loading="isLoading"
- :variables="projectVariables"
- @add-variable="addVariable"
- @delete-variable="deleteVariable"
- @update-variable="updateVariable"
+ component-name="ProjectVariables"
+ :full-path="projectFullPath"
+ :mutation-data="$options.mutationData"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue
new file mode 100644
index 00000000000..48081fe28f1
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue
@@ -0,0 +1,226 @@
+<script>
+import { createAlert } from '~/flash';
+import { __ } from '~/locale';
+import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ environmentFetchErrorText,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import CiVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ CiVariableSettings,
+ },
+ inject: ['endpoint'],
+ props: {
+ areScopedVariablesAvailable: {
+ required: true,
+ type: Boolean,
+ },
+ componentName: {
+ required: true,
+ type: String,
+ },
+ fullPath: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ id: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ mutationData: {
+ required: true,
+ type: Object,
+ validator: (obj) => {
+ const hasValidKeys = Object.keys(obj).includes(
+ ADD_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ );
+
+ const hasValidValues = Object.values(obj).reduce((acc, val) => {
+ return acc && typeof val === 'object';
+ }, true);
+
+ return hasValidKeys && hasValidValues;
+ },
+ },
+ refetchAfterMutation: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ queryData: {
+ required: true,
+ type: Object,
+ validator: (obj) => {
+ const { ciVariables, environments } = obj;
+ const hasCiVariablesKey = Boolean(ciVariables);
+ let hasCorrectEnvData = true;
+
+ const hasCorrectVariablesData =
+ typeof ciVariables?.lookup === 'function' && typeof ciVariables.query === 'object';
+
+ if (environments) {
+ hasCorrectEnvData =
+ typeof environments?.lookup === 'function' && typeof environments.query === 'object';
+ }
+
+ return hasCiVariablesKey && hasCorrectVariablesData && hasCorrectEnvData;
+ },
+ },
+ },
+ data() {
+ return {
+ ciVariables: [],
+ hasNextPage: false,
+ isInitialLoading: true,
+ isLoadingMoreItems: false,
+ loadingCounter: 0,
+ pageInfo: {},
+ };
+ },
+ apollo: {
+ ciVariables: {
+ query() {
+ return this.queryData.ciVariables.query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath || undefined,
+ };
+ },
+ update(data) {
+ return this.queryData.ciVariables.lookup(data)?.nodes || [];
+ },
+ result({ data }) {
+ this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo;
+ this.hasNextPage = this.pageInfo?.hasNextPage || false;
+
+ // Because graphQL has a limit of 100 items,
+ // we batch load all the variables by making successive queries
+ // to keep the same UX. As a safeguard, we make sure that we cannot go over
+ // 20 consecutive API calls, which means 2000 variables loaded maximum.
+ if (!this.hasNextPage) {
+ this.isLoadingMoreItems = false;
+ } else if (this.loadingCounter < 20) {
+ this.hasNextPage = false;
+ this.fetchMoreVariables();
+ this.loadingCounter += 1;
+ } else {
+ createAlert({ message: this.$options.tooManyCallsError });
+ reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {});
+ }
+ },
+ error() {
+ this.isLoadingMoreItems = false;
+ this.hasNextPage = false;
+ createAlert({ message: variableFetchErrorText });
+ },
+ watchLoading(flag) {
+ if (!flag) {
+ this.isInitialLoading = false;
+ }
+ },
+ },
+ environments: {
+ query() {
+ return this.queryData?.environments?.query || {};
+ },
+ skip() {
+ return !this.queryData?.environments?.query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return mapEnvironmentNames(this.queryData.environments.lookup(data)?.nodes);
+ },
+ error() {
+ createAlert({ message: environmentFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return (
+ (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
+ this.$apollo.queries.environments.loading ||
+ this.isLoadingMoreItems
+ );
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ fetchMoreVariables() {
+ this.isLoadingMoreItems = true;
+
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ },
+ });
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.mutationData[mutationAction];
+
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation,
+ variables: {
+ endpoint: this.endpoint,
+ fullPath: this.fullPath || undefined,
+ id: this.id || undefined,
+ variable,
+ },
+ });
+
+ if (data.ciVariableMutation?.errors?.length) {
+ const { errors } = data.ciVariableMutation;
+ createAlert({ message: errors[0] });
+ } else if (this.refetchAfterMutation) {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.ciVariables.refetch();
+ }
+ } catch (e) {
+ createAlert({ message: genericMutationErrorText });
+ }
+ },
+ },
+ i18n: {
+ tooManyCallsError: __('Maximum number of variables loaded (2000)'),
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :is-loading="isLoading"
+ :variables="ciVariables"
+ :environments="environments"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
index eba4b0c32f8..9208c34f154 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) {
- addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
index 96eb8c794bc..a79b98f5e95 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) {
- deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
index c0388507bb8..ddea753bf90 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
@@ -1,7 +1,7 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) {
- updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariableMutation: updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
ciVariables {
nodes {
...BaseCiVariable
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
index f8e4dc55fa4..c44ee2ecc1d 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation addGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- addGroupVariable(
+mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: addGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
index 310e4a6e551..53e9b411dd2 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation deleteGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- deleteGroupVariable(
+mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: deleteGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
index 5291942eb87..2dddca14bd8 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation updateGroupVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $groupId: ID!
-) {
- updateGroupVariable(
+mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: updateGroupVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- groupId: $groupId
+ id: $id
) @client {
group {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
index ab3a46da854..39504770e33 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
@@ -1,16 +1,11 @@
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
-mutation addProjectVariable(
- $variable: CiVariable!
- $endpoint: String!
- $fullPath: ID!
- $projectId: ID!
-) {
- addProjectVariable(
+mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) {
+ ciVariableMutation: addProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
index e83dc9a5e5e..f55c255e332 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
@@ -4,13 +4,13 @@ mutation deleteProjectVariable(
$variable: CiVariable!
$endpoint: String!
$fullPath: ID!
- $projectId: ID!
+ $id: ID!
) {
- deleteProjectVariable(
+ ciVariableMutation: deleteProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
index 4788911431b..fc589e8a939 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
@@ -4,13 +4,13 @@ mutation updateProjectVariable(
$variable: CiVariable!
$endpoint: String!
$fullPath: ID!
- $projectId: ID!
+ $id: ID!
) {
- updateProjectVariable(
+ ciVariableMutation: updateProjectVariable(
variable: $variable
endpoint: $endpoint
fullPath: $fullPath
- projectId: $projectId
+ id: $id
) @client {
project {
id
diff --git a/app/assets/javascripts/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci_variable_list/graphql/settings.js
index ecdc4f220bd..02f6c226b0f 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci_variable_list/graphql/settings.js
@@ -36,12 +36,12 @@ const mapVariableTypes = (variables = [], kind) => {
});
};
-const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
+const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
project: {
__typename: GRAPHQL_PROJECT_TYPE,
- id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, projectId),
+ id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id),
ciVariables: {
__typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`,
pageInfo: {
@@ -57,12 +57,12 @@ const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
};
};
-const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
+const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
group: {
__typename: GRAPHQL_GROUP_TYPE,
- id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, groupId),
+ id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id),
ciVariables: {
__typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`,
pageInfo: {
@@ -95,20 +95,13 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
};
};
-async function callProjectEndpoint({
- endpoint,
- fullPath,
- variable,
- projectId,
- cache,
- destroy = false,
-}) {
+async function callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy = false }) {
try {
const { data } = await axios.patch(endpoint, {
variables_attributes: [prepareVariableForApi({ variable, destroy })],
});
- const graphqlData = prepareProjectGraphQLResponse({ data, projectId });
+ const graphqlData = prepareProjectGraphQLResponse({ data, id });
cache.writeQuery({
query: getProjectVariables,
@@ -122,26 +115,19 @@ async function callProjectEndpoint({
} catch (e) {
return prepareProjectGraphQLResponse({
data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }),
- projectId,
+ id,
errors: [...e.response.data],
});
}
}
-const callGroupEndpoint = async ({
- endpoint,
- fullPath,
- variable,
- groupId,
- cache,
- destroy = false,
-}) => {
+const callGroupEndpoint = async ({ endpoint, fullPath, variable, id, cache, destroy = false }) => {
try {
const { data } = await axios.patch(endpoint, {
variables_attributes: [prepareVariableForApi({ variable, destroy })],
});
- const graphqlData = prepareGroupGraphQLResponse({ data, groupId });
+ const graphqlData = prepareGroupGraphQLResponse({ data, id });
cache.writeQuery({
query: getGroupVariables,
@@ -152,7 +138,7 @@ const callGroupEndpoint = async ({
} catch (e) {
return prepareGroupGraphQLResponse({
data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }),
- groupId,
+ id,
errors: [...e.response.data],
});
}
@@ -182,23 +168,23 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false })
export const resolvers = {
Mutation: {
- addProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ addProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache });
},
- updateProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ updateProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache });
},
- deleteProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
- return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy: true });
+ deleteProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true });
},
- addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ addGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache });
},
- updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ updateGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache });
},
- deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
- return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true });
+ deleteGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true });
},
addAdminVariable: async (_, { endpoint, variable }, { cache }) => {
return callAdminEndpoint({ endpoint, variable, cache });
@@ -238,7 +224,7 @@ export const cacheConfig = {
Project: {
fields: {
ciVariables: {
- keyArgs: ['fullPath', 'endpoint', 'projectId'],
+ keyArgs: ['fullPath', 'endpoint', 'id'],
merge: mergeVariables,
},
},
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 38c7af8cf95..15e7ef7d62c 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -5,7 +5,6 @@ import { concatPagination } from '@apollo/client/utilities';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
-import { WIDGET_TYPE_MILESTONE } from '~/work_items/constants';
export const config = {
typeDefs,
@@ -15,10 +14,6 @@ export const config = {
// eslint-disable-next-line no-underscore-dangle
return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
-
- possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemMilestone'],
- },
typePolicies: {
Project: {
fields: {
@@ -29,28 +24,6 @@ export const config = {
},
WorkItem: {
fields: {
- mockWidgets: {
- read(widgets) {
- return (
- widgets || [
- {
- __typename: 'LocalWorkItemMilestone',
- type: WIDGET_TYPE_MILESTONE,
- nodes: [
- {
- dueDate: null,
- expired: false,
- id: 'gid://gitlab/Milestone/30',
- title: 'v4.0',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Milestone',
- },
- ],
- },
- ]
- );
- },
- },
widgets: {
merge(existing = [], incoming) {
if (existing.length === 0) {
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
index 4b91872d80d..fe99f3e1fdd 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -117,7 +117,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
}
},
async addProject() {
@@ -140,7 +140,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
} finally {
this.clearTargetProjectPath();
this.getProjects();
@@ -166,7 +166,7 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- createAlert({ message: error });
+ createAlert({ message: error.message });
} finally {
this.getProjects();
}
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
index 82ef3371d91..ce33478cbee 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -10,14 +10,21 @@ export default {
{
key: 'project',
label: __('Projects that can be accessed'),
- tdClass: 'gl-p-5!',
- columnClass: 'gl-w-85p',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
},
{
key: 'actions',
label: '',
- tdClass: 'gl-p-5! gl-text-right',
- columnClass: 'gl-w-15p',
+ tdClass: 'gl-text-right',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-10p',
},
],
components: {
@@ -57,7 +64,11 @@ export default {
</template>
<template #cell(project)="{ item }">
- {{ item.name }}
+ <span data-testid="token-access-project-name">{{ item.name }}</span>
+ </template>
+
+ <template #cell(namespace)="{ item }">
+ <span data-testid="token-access-project-namespace">{{ item.namespace.fullPath }}</span>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
index 664991bc110..a243095f1b4 100644
--- a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
+++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
@@ -6,6 +6,10 @@ query getProjectsWithCIJobTokenScope($fullPath: ID!) {
nodes {
id
name
+ namespace {
+ id
+ fullPath
+ }
fullPath
}
}
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 1b89bd324c6..f349aa78bac 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -20,6 +20,11 @@ export default {
required: false,
default: () => ({}),
},
+ icon: {
+ type: String,
+ required: false,
+ default: 'question-o',
+ },
},
methods: {
targetFn() {
@@ -30,7 +35,7 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" />
+ <gl-button ref="popoverTrigger" variant="link" :icon="icon" :aria-label="__('Help')" />
<gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
index e0ba4b730a7..3540ac6caf1 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
@@ -22,7 +22,7 @@ const format = (node, kind = '') => {
.split(newlineRegex)
.map((newline) => generateHLJSTag(kind, newline, true))
.join('\n');
- } else if (node.kind) {
+ } else if (node.kind || node.sublanguage) {
const { children } = node;
if (children.length && children.length === 1) {
buffer += format(children[0], node.kind);
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index a2d7b3c4b80..a29d0e38570 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -32,6 +32,7 @@ import {
import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
+import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
import { getWorkItemQuery } from '../utils';
@@ -170,6 +171,17 @@ export default {
return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id;
},
},
+ {
+ document: workItemMilestoneSubscription,
+ variables() {
+ return {
+ issuableId: this.workItem.id,
+ };
+ },
+ skip() {
+ return !this.isWidgetPresent(WIDGET_TYPE_MILESTONE) || !this.workItem?.id;
+ },
+ },
],
},
},
@@ -229,7 +241,7 @@ export default {
return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
},
workItemMilestone() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE);
+ return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
},
fetchByIid() {
return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path);
@@ -457,8 +469,10 @@ export default {
<work-item-milestone
v-if="workItemMilestone"
:work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.nodes[0]"
+ :work-item-milestone="workItemMilestone.milestone"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
:can-update="canUpdate"
:full-path="fullPath"
@error="updateError = $event"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 9a866ca23c2..c45198bd5d3 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -89,6 +89,9 @@ export default {
issuableIteration() {
return this.parentIssue?.iteration;
},
+ issuableMilestone() {
+ return this.parentIssue?.milestone;
+ },
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@@ -309,6 +312,7 @@ export default {
:children-ids="childrenIds"
:parent-confidential="confidential"
:parent-iteration="issuableIteration"
+ :parent-milestone="issuableMilestone"
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 839d0014b41..07a253369bb 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -40,6 +40,11 @@ export default {
required: false,
default: () => {},
},
+ parentMilestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
apollo: {
workItemTypes: {
@@ -63,6 +68,27 @@ export default {
};
},
computed: {
+ workItemInput() {
+ let workItemInput = {
+ title: this.search?.title || this.search,
+ projectPath: this.projectPath,
+ workItemTypeId: this.taskWorkItemType,
+ hierarchyWidget: {
+ parentId: this.issuableGid,
+ },
+ confidential: this.parentConfidential,
+ };
+
+ if (this.associateMilestone) {
+ workItemInput = {
+ ...workItemInput,
+ milestoneWidget: {
+ milestoneId: this.parentMilestoneId,
+ },
+ };
+ }
+ return workItemInput;
+ },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -93,6 +119,12 @@ export default {
associateIteration() {
return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled;
},
+ parentMilestoneId() {
+ return this.parentMilestone?.id;
+ },
+ associateMilestone() {
+ return this.parentMilestoneId && this.workItemsMvc2Enabled;
+ },
},
methods: {
getIdFromGraphQLId,
@@ -132,15 +164,7 @@ export default {
.mutate({
mutation: createWorkItemMutation,
variables: {
- input: {
- title: this.search?.title || this.search,
- projectPath: this.projectPath,
- workItemTypeId: this.taskWorkItemType,
- hierarchyWidget: {
- parentId: this.issuableGid,
- },
- confidential: this.parentConfidential,
- },
+ input: this.workItemInput,
},
})
.then(({ data }) => {
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
index c4a36e36555..a8d3b57aae0 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -11,10 +11,10 @@ import {
import * as Sentry from '@sentry/browser';
import { debounce } from 'lodash';
import Tracking from '~/tracking';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
sprintfWorkItem,
@@ -33,6 +33,7 @@ export default {
MILESTONE_FETCH_ERROR: s__(
'WorkItem|Something went wrong while fetching milestones. Please try again.',
),
+ EXPIRED_TEXT: __('(expired)'),
},
components: {
GlFormGroup,
@@ -68,6 +69,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -90,8 +100,13 @@ export default {
emptyPlaceholder() {
return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE;
},
+ expired() {
+ return this.localMilestone?.expired ? ` ${this.$options.i18n.EXPIRED_TEXT}` : '';
+ },
dropdownText() {
- return this.localMilestone?.title || this.emptyPlaceholder;
+ return this.localMilestone?.title
+ ? `${this.localMilestone?.title}${this.expired}`
+ : this.emptyPlaceholder;
},
isLoadingMilestones() {
return this.$apollo.queries.milestones.loading;
@@ -106,6 +121,14 @@ export default {
};
},
},
+ watch: {
+ workItemMilestone: {
+ handler(newVal) {
+ this.localMilestone = newVal;
+ },
+ deep: true,
+ },
+ },
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
@@ -160,12 +183,13 @@ export default {
this.updateInProgress = true;
this.$apollo
.mutate({
- mutation: localUpdateWorkItemMutation,
+ mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
- milestone: {
- milestoneId: this.localMilestone?.id,
+ milestoneWidget: {
+ milestoneId:
+ this.localMilestone?.id === 'no-milestone-id' ? null : this.localMilestone?.id,
},
},
},
@@ -240,6 +264,7 @@ export default {
@click="handleMilestoneClick(milestone)"
>
{{ milestone.title }}
+ <template v-if="milestone.expired">{{ $options.i18n.EXPIRED_TEXT }}</template>
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text>
diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
index 6edb6c89f16..daeb58c0947 100644
--- a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
@@ -4,6 +4,9 @@ query issuableDetails($fullPath: ID!, $iid: String) {
issuable: issue(iid: $iid) {
id
confidential
+ milestone {
+ id
+ }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
new file mode 100644
index 00000000000..58140aff89e
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
@@ -0,0 +1,5 @@
+fragment MilestoneFragment on Milestone {
+ expired
+ id
+ title
+}
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 36779dfe11e..fda71fabe22 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,6 +1,5 @@
enum LocalWidgetType {
ASSIGNEES
- MILESTONE
}
interface LocalWorkItemWidget {
@@ -12,11 +11,6 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget {
nodes: [UserCore]
}
-type LocalWorkItemMilestone implements LocalWorkItemWidget {
- type: LocalWidgetType!
- nodes: [Milestone!]
-}
-
extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
@@ -29,14 +23,9 @@ input LocalUserInput {
avatarUrl: String
}
-input LocalMilestoneInput {
- milestoneId: ID!
-}
-
input LocalUpdateWorkItemInput {
id: WorkItemID!
assignees: [LocalUserInput!]
- milestone: LocalMilestoneInput!
}
type LocalWorkItemPayload {
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index fa0ab56df75..3b46fed97ec 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -3,16 +3,5 @@
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
- mockWidgets @client {
- ... on LocalWorkItemMilestone {
- type
- nodes {
- id
- title
- expired
- dueDate
- }
- }
- }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
index 83f0ce32e24..4c3be007d96 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
@@ -6,17 +6,6 @@ query workItemByIid($fullPath: ID!, $iid: String) {
workItems(iid: $iid) {
nodes {
...WorkItem
- mockWidgets @client {
- ... on LocalWorkItemMilestone {
- type
- nodes {
- id
- title
- expired
- dueDate
- }
- }
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
new file mode 100644
index 00000000000..f5163003fe5
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql
@@ -0,0 +1,17 @@
+#import "~/work_items/graphql/milestone.fragment.graphql"
+
+subscription issuableMilestone($issuableId: IssuableID!) {
+ issuableMilestoneUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ widgets {
+ ... on WorkItemWidgetMilestone {
+ type
+ milestone {
+ ...MilestoneFragment
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index d404cfb10ed..b9715c21c27 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -1,5 +1,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/work_items/graphql/milestone.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
@@ -49,4 +50,10 @@ fragment WorkItemWidgets on WorkItemWidget {
}
}
}
+ ... on WorkItemWidgetMilestone {
+ type
+ milestone {
+ ...MilestoneFragment
+ }
+ }
}
diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb
index 6988da4deca..fdb08c6572f 100644
--- a/app/controllers/concerns/access_tokens_actions.rb
+++ b/app/controllers/concerns/access_tokens_actions.rb
@@ -17,7 +17,7 @@ module AccessTokensActions
respond_to do |format|
format.html
format.json do
- render json: @active_resource_access_tokens
+ render json: @active_access_tokens
end
end
end
@@ -30,7 +30,7 @@ module AccessTokensActions
if token_response.success?
@resource_access_token = token_response.payload[:access_token]
render json: { new_token: @resource_access_token.token,
- active_access_tokens: active_resource_access_tokens }, status: :ok
+ active_access_tokens: active_access_tokens }, status: :ok
else
render json: { errors: token_response.errors }, status: :unprocessable_entity
end
@@ -69,37 +69,10 @@ module AccessTokensActions
resource.members.load
@scopes = Gitlab::Auth.resource_bot_scopes
- @active_resource_access_tokens = active_resource_access_tokens
+ @active_access_tokens = active_access_tokens
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
- def active_resource_access_tokens
- tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users
-
- if Feature.enabled?('access_token_pagination')
- tokens = tokens.page(page)
- add_pagination_headers(tokens)
- end
-
- represent(tokens)
- end
-
- def add_pagination_headers(relation)
- Gitlab::Pagination::OffsetHeaderBuilder.new(
- request_context: self,
- per_page: relation.limit_value,
- page: relation.current_page,
- next_page: relation.next_page,
- prev_page: relation.prev_page,
- total: relation.total_count,
- params: params.permit(:page)
- ).execute
- end
-
- def page
- (params[:page] || 1).to_i
- end
-
def finder(options = {})
PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options))
end
diff --git a/app/controllers/concerns/render_access_tokens.rb b/app/controllers/concerns/render_access_tokens.rb
new file mode 100644
index 00000000000..b0bbad7e37f
--- /dev/null
+++ b/app/controllers/concerns/render_access_tokens.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+module RenderAccessTokens
+ extend ActiveSupport::Concern
+
+ def active_access_tokens
+ tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users
+
+ if Feature.enabled?('access_token_pagination')
+ tokens = tokens.page(page)
+ add_pagination_headers(tokens)
+ end
+
+ represent(tokens)
+ end
+
+ def add_pagination_headers(relation)
+ Gitlab::Pagination::OffsetHeaderBuilder.new(
+ request_context: self,
+ per_page: relation.limit_value,
+ page: relation.current_page,
+ next_page: relation.next_page,
+ prev_page: relation.prev_page,
+ total: relation.total_count,
+ params: params.permit(:page, :per_page)
+ ).execute
+ end
+
+ def page
+ (params[:page] || 1).to_i
+ end
+end
diff --git a/app/controllers/groups/settings/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb
index f01b2b779e3..d86ddcfe2d0 100644
--- a/app/controllers/groups/settings/access_tokens_controller.rb
+++ b/app/controllers/groups/settings/access_tokens_controller.rb
@@ -3,6 +3,7 @@
module Groups
module Settings
class AccessTokensController < Groups::ApplicationController
+ include RenderAccessTokens
include AccessTokensActions
layout 'group_settings'
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 4cf26d3e1e2..1663aa61f62 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
+ include RenderAccessTokens
+
feature_category :authentication_and_authorization
before_action :check_personal_access_tokens_enabled
@@ -16,7 +18,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: @active_personal_access_tokens
+ render json: @active_access_tokens
end
end
end
@@ -30,7 +32,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
if result.success?
render json: { new_token: @personal_access_token.token,
- active_access_tokens: active_personal_access_tokens }, status: :ok
+ active_access_tokens: active_access_tokens }, status: :ok
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
@@ -56,36 +58,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def set_index_vars
@scopes = Gitlab::Auth.available_scopes_for(current_user)
- @active_personal_access_tokens = active_personal_access_tokens
+ @active_access_tokens = active_access_tokens
end
- def active_personal_access_tokens
- tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute
-
- if Feature.enabled?('access_token_pagination')
- tokens = tokens.page(page)
- add_pagination_headers(tokens)
- end
-
+ def represent(tokens)
::PersonalAccessTokenSerializer.new.represent(tokens)
end
- def add_pagination_headers(relation)
- Gitlab::Pagination::OffsetHeaderBuilder.new(
- request_context: self,
- per_page: relation.limit_value,
- page: relation.current_page,
- next_page: relation.next_page,
- prev_page: relation.prev_page,
- total: relation.total_count,
- params: params.permit(:page)
- ).execute
- end
-
- def page
- (params[:page] || 1).to_i
- end
-
def check_personal_access_tokens_enabled
render_404 if Gitlab::CurrentSettings.personal_access_tokens_disabled?
end
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index bac35583a97..0884816ef62 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Settings
class AccessTokensController < Projects::ApplicationController
+ include RenderAccessTokens
include AccessTokensActions
layout 'project_settings'
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
index 0704a845bb0..fb5fa4465f9 100644
--- a/app/graphql/resolvers/blobs_resolver.rb
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -5,7 +5,7 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Tree::BlobType.connection_type, null: true
- authorize :download_code
+ authorize :read_code
calls_gitaly!
alias_method :repository, :object
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index 1ae88f98a9a..5dd862c7388 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -4,7 +4,7 @@ module Types
class CommitType < BaseObject
graphql_name 'Commit'
- authorize :download_code
+ authorize :read_code
present_using CommitPresenter
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 900f37a4b72..771dad00fb3 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -603,7 +603,7 @@ module Types
end
def sast_ci_configuration
- return unless Ability.allowed?(current_user, :download_code, object)
+ return unless Ability.allowed?(current_user, :read_code, object)
::Security::CiConfiguration::SastParserService.new(object).configuration
end
diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb
index e7632c17cca..1afd2cc3fef 100644
--- a/app/graphql/types/projects/branch_rule_type.rb
+++ b/app/graphql/types/projects/branch_rule_type.rb
@@ -8,6 +8,8 @@ module Types
accepts ::ProtectedBranch
authorize :read_protected_branch
+ alias_method :branch_rule, :object
+
field :name,
type: GraphQL::Types::String,
null: false,
@@ -20,6 +22,12 @@ module Types
calls_gitaly: true,
description: "Check if this branch rule protects the project's default branch."
+ field :matching_branches_count,
+ type: GraphQL::Types::Int,
+ null: false,
+ calls_gitaly: true,
+ description: 'Number of existing branches that match this branch rule.'
+
field :branch_protection,
type: Types::BranchRules::BranchProtectionType,
null: false,
@@ -35,6 +43,10 @@ module Types
Types::TimeType,
null: false,
description: 'Timestamp of when the branch rule was last updated.'
+
+ def matching_branches_count
+ branch_rule.matching(branch_rule.project.repository.branch_names).count
+ end
end
end
end
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
index 6bc767152e8..2258adc131c 100644
--- a/app/graphql/types/release_links_type.rb
+++ b/app/graphql/types/release_links_type.rb
@@ -14,12 +14,12 @@ module Types
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.',
- authorize: :download_code
+ authorize: :read_code
field :closed_merge_requests_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.',
- authorize: :download_code
+ authorize: :read_code
field :edit_url, GraphQL::Types::String, null: true,
description: "HTTP URL of the release's edit page.",
authorize: :update_release
@@ -27,17 +27,17 @@ module Types
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.',
- authorize: :download_code
+ authorize: :read_code
field :opened_issues_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=open`.',
- authorize: :download_code
+ authorize: :read_code
field :opened_merge_requests_url,
GraphQL::Types::String,
null: true,
description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.',
- authorize: :download_code
+ authorize: :read_code
field :self_url, GraphQL::Types::String, null: true,
description: 'HTTP URL of the release.'
end
diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb
index e05a2926ac1..e1959738c4b 100644
--- a/app/graphql/types/release_source_type.rb
+++ b/app/graphql/types/release_source_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'ReleaseSource'
description 'Represents the source code attached to a release in a particular format'
- authorize :download_code
+ authorize :read_code
field :format, GraphQL::Types::String, null: true,
description: 'Format of the source.'
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index d70fe05c906..a20e53ad1bd 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -39,7 +39,7 @@ module Types
description: 'Name of the tag associated with the release.'
field :tag_path, GraphQL::Types::String, null: true,
description: 'Relative web path to the tag associated with the release.',
- authorize: :download_code
+ authorize: :read_code
field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?,
description: 'Indicates the release is an upcoming release.'
field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?,
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index ba94f59ab6c..ab5d1bd8c9e 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -4,7 +4,7 @@ module Types
class RepositoryType < BaseObject
graphql_name 'Repository'
- authorize :download_code
+ authorize :read_code
field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true,
description: 'Blobs contained within the repository'
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3b484152f3d..f44ba124fe2 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -172,7 +172,7 @@ module Ci
add_authentication_token_field :token, encrypted: :required
- before_save :ensure_token
+ before_save :ensure_token, unless: :assign_token_on_scheduling?
after_save :stick_build_if_status_changed
@@ -247,6 +247,14 @@ module Ci
!build.waiting_for_deployment_approval? # If false is returned, it stops the transition
end
+ before_transition any => [:pending] do |build, transition|
+ if build.assign_token_on_scheduling?
+ build.ensure_token
+ end
+
+ true
+ end
+
after_transition created: :scheduled do |build|
build.run_after_commit do
Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id)
@@ -1128,6 +1136,10 @@ module Ci
end
end
+ def assign_token_on_scheduling?
+ ::Feature.enabled?(:ci_assign_job_token_on_scheduling, project)
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb
index f1ac734635d..4d267dc69d0 100644
--- a/app/models/concerns/file_store_mounter.rb
+++ b/app/models/concerns/file_store_mounter.rb
@@ -1,31 +1,35 @@
# frozen_string_literal: true
module FileStoreMounter
+ ALLOWED_FILE_FIELDS = %i[file signed_file].freeze
+
extend ActiveSupport::Concern
class_methods do
- # When `skip_store_file: true` is used, the model MUST explicitly call `store_file_now!`
- def mount_file_store_uploader(uploader, skip_store_file: false)
- mount_uploader(:file, uploader)
+ # When `skip_store_file: true` is used, the model MUST explicitly call `store_#{file_field}_now!`
+ def mount_file_store_uploader(uploader, skip_store_file: false, file_field: :file)
+ raise ArgumentError, "file_field not allowed: #{file_field}" unless ALLOWED_FILE_FIELDS.include?(file_field)
+
+ mount_uploader(file_field, uploader)
+
+ define_method("update_#{file_field}_store") do
+ # The file.object_store is set during `uploader.store!` and `uploader.migrate!`
+ update_column("#{file_field}_store", public_send(file_field).object_store) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ define_method("store_#{file_field}_now!") do
+ public_send("store_#{file_field}!") # rubocop:disable GitlabSecurity/PublicSend
+ public_send("update_#{file_field}_store") # rubocop:disable GitlabSecurity/PublicSend
+ end
if skip_store_file
- skip_callback :save, :after, :store_file!
+ skip_callback :save, :after, "store_#{file_field}!".to_sym
return
end
# This hook is a no-op when the file is uploaded after_commit
- after_save :update_file_store, if: :saved_change_to_file?
+ after_save "update_#{file_field}_store".to_sym, if: "saved_change_to_#{file_field}?".to_sym
end
end
-
- def update_file_store
- # The file.object_store is set during `uploader.store!` and `uploader.migrate!`
- update_column(:file_store, file.object_store)
- end
-
- def store_file_now!
- store_file!
- update_file_store
- end
end
diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb
index 1520ec0828e..75fd45d13a9 100644
--- a/app/models/concerns/packages/debian/distribution.rb
+++ b/app/models/concerns/packages/debian/distribution.rb
@@ -85,8 +85,7 @@ module Packages
scope :with_codename_or_suite, ->(codename_or_suite) { with_codename(codename_or_suite).or(with_suite(codename_or_suite)) }
mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader
- mount_uploader :signed_file, Packages::Debian::DistributionReleaseFileUploader
- after_save :update_signed_file_store, if: :saved_change_to_signed_file?
+ mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader, file_field: :signed_file
def component_names
components.pluck(:name).sort
@@ -119,12 +118,6 @@ module Packages
self.class.with_container(container).with_codename(suite).exists?
end
-
- def update_signed_file_store
- # The signed_file.object_store is set during `uploader.store!`
- # which happens after object is inserted/updated
- self.update_column(:signed_file_store, signed_file.object_store)
- end
end
end
end
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 69b76b7efa9..ff41dc35e57 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -2,6 +2,7 @@
class ProjectSetting < ApplicationRecord
include ::Gitlab::Utils::StrongMemoize
+ include EachBatch
ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos android).freeze
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index fc47ece6199..70fe6324712 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -4,13 +4,13 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
presents ::Release, as: :release
def commit_path
- return unless release.commit && can_download_code?
+ return unless release.commit && can_read_code?
project_commit_path(project, release.commit.id)
end
def tag_path
- return unless can_download_code?
+ return unless can_read_code?
project_tag_path(project, release.tag)
end
@@ -47,7 +47,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
delegator_override :assets_count
def assets_count
- if can_download_code?
+ if can_read_code?
release.assets_count
else
release.assets_count(except: [:sources])
@@ -67,8 +67,8 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
private
- def can_download_code?
- can?(current_user, :download_code, project)
+ def can_read_code?
+ can?(current_user, :read_code, project)
end
def params_for_issues_and_mrs(state: 'opened')
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 60e3d3418e9..d4c00a4dcec 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -8,6 +8,15 @@ module Users
def initialize(current_user)
@current_user = current_user
+
+ @scheduled_records_gauge = Gitlab::Metrics.gauge(
+ :gitlab_ghost_user_migration_scheduled_records_total,
+ 'The total number of scheduled ghost user migrations'
+ )
+ @lag_gauge = Gitlab::Metrics.gauge(
+ :gitlab_ghost_user_migration_lag_seconds,
+ 'The waiting time in seconds of the oldest scheduled record for ghost user migration'
+ )
end
# Asynchronously destroys +user+
@@ -64,6 +73,39 @@ module Users
Users::GhostUserMigration.create!(user: user,
initiator_user: current_user,
hard_delete: hard_delete)
+
+ update_metrics
+ end
+
+ private
+
+ attr_reader :scheduled_records_gauge, :lag_gauge
+
+ def update_metrics
+ update_scheduled_records_gauge
+ update_lag_gauge
+ end
+
+ def update_scheduled_records_gauge
+ # We do not want to issue unbounded COUNT() queries, hence we limit the
+ # query to count 1001 records and then approximate the result.
+ count = Users::GhostUserMigration.limit(1001).count
+
+ if count == 1001
+ # more than 1000 records, approximate count
+ min = Users::GhostUserMigration.minimum(:id) || 0
+ max = Users::GhostUserMigration.maximum(:id) || 0
+
+ scheduled_records_gauge.set({}, max - min)
+ else
+ # less than 1000 records, count is accurate
+ scheduled_records_gauge.set({}, count)
+ end
+ end
+
+ def update_lag_gauge
+ oldest_job = Users::GhostUserMigration.first
+ lag_gauge.set({}, Time.current - oldest_job.created_at)
end
end
end
diff --git a/app/views/groups/settings/_remove.html.haml b/app/views/groups/settings/_remove.html.haml
index 8571b93364b..a37a0f8053b 100644
--- a/app/views/groups/settings/_remove.html.haml
+++ b/app/views/groups/settings/_remove.html.haml
@@ -1,6 +1,6 @@
- remove_form_id = local_assigns.fetch(:remove_form_id, nil)
- if group.adjourned_deletion?
- = render_if_exists 'groups/settings/adjourned_deletion', group: group, remove_form_id: remove_form_id
+ = render_if_exists 'groups/settings/delayed_deletion', group: group, remove_form_id: remove_form_id
- else
= render 'groups/settings/permanent_deletion', group: group, remove_form_id: remove_form_id
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 5e3d814687e..309633471a5 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -39,6 +39,5 @@
prefix: :resource_access_token,
help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
} }
-
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 10e38ab63f7..42ffd155647 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -76,7 +76,7 @@
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:assigned]
%li
- = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
+ = link_to reviewer_mrs_dashboard_path, class: 'dashboard-shortcuts-review_requests gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Review requests for you')
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:review_requested]
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 636defb3f10..82df6b1b2c7 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -25,6 +25,6 @@
scopes: @scopes,
help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_personal_access_tokens.to_json } }
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
#js-tokens-app{ data: { tokens_data: tokens_app_data } }
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 9f598ffb2d1..f6c5c4e2950 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -40,5 +40,5 @@
description_prefix: :project_access_token,
help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
} }
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 7adc38f833f..daac65b2722 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -65,7 +65,7 @@
= gl_loading_icon(inline: true)
- if issuable_sidebar.dig(:features_available, :health_status)
- .js-sidebar-health-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
+ .js-sidebar-health-status-widget-root{ data: sidebar_status_data(issuable_sidebar, @project) }
- if issuable_sidebar.has_key?(:confidential)
-# haml-lint:disable InlineJavaScript
diff --git a/config/feature_flags/development/ci_assign_job_token_on_scheduling.yml b/config/feature_flags/development/ci_assign_job_token_on_scheduling.yml
new file mode 100644
index 00000000000..179fef03d5e
--- /dev/null
+++ b/config/feature_flags/development/ci_assign_job_token_on_scheduling.yml
@@ -0,0 +1,8 @@
+---
+name: ci_assign_job_token_on_scheduling
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/103377
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382042
+milestone: '15.6'
+type: development
+group: group::pipeline execution
+default_enabled: false
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index c991c8de489..60695f3321c 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -770,7 +770,7 @@ Gitlab.ee do
Settings.cron_jobs['iterations_generator_worker']['cron'] ||= '5 0 * * *'
Settings.cron_jobs['iterations_generator_worker']['job_class'] = 'Iterations::Cadences::ScheduleCreateIterationsWorker'
Settings.cron_jobs['vulnerability_statistics_schedule_worker'] ||= Settingslogic.new({})
- Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1 * * *'
+ Settings.cron_jobs['vulnerability_statistics_schedule_worker']['cron'] ||= '15 1,20 * * *'
Settings.cron_jobs['vulnerability_statistics_schedule_worker']['job_class'] = 'Vulnerabilities::Statistics::ScheduleWorker'
Settings.cron_jobs['vulnerability_historical_statistics_deletion_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['vulnerability_historical_statistics_deletion_worker']['cron'] ||= '15 3 * * *'
diff --git a/config/open_api.yml b/config/open_api.yml
index 6f349e66d74..5bae48920fb 100644
--- a/config/open_api.yml
+++ b/config/open_api.yml
@@ -25,6 +25,8 @@ metadata:
description: Operations related to clusters
- name: ci_resource_groups
description: Operations to manage job concurrency with resource groups
+ - name: dependency_proxy
+ description: Operations to manage dependency proxy for a groups
- name: deploy_keys
description: Operations related to deploy keys
- name: deploy_tokens
@@ -67,6 +69,8 @@ metadata:
description: Operations related to release assets (links)
- name: releases
description: Operations related to releases
+ - name: resource_milestone_events
+ description: Operations about resource milestone events
- name: suggestions
description: Operations related to suggestions
- name: system_hooks
diff --git a/db/migrate/20221102225800_add_max_seats_used_changed_at_index_to_gitlab_subscriptions.rb b/db/migrate/20221102225800_add_max_seats_used_changed_at_index_to_gitlab_subscriptions.rb
new file mode 100644
index 00000000000..b5cf8289673
--- /dev/null
+++ b/db/migrate/20221102225800_add_max_seats_used_changed_at_index_to_gitlab_subscriptions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddMaxSeatsUsedChangedAtIndexToGitlabSubscriptions < Gitlab::Database::Migration[2.0]
+ INDEX_NAME = 'index_gitlab_subscriptions_on_max_seats_used_changed_at'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :gitlab_subscriptions, [:max_seats_used_changed_at, :namespace_id], name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :gitlab_subscriptions, INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20221108121322_add_supporting_index_for_vulnerabilities_feedback_migration.rb b/db/post_migrate/20221108121322_add_supporting_index_for_vulnerabilities_feedback_migration.rb
new file mode 100644
index 00000000000..c77930512d2
--- /dev/null
+++ b/db/post_migrate/20221108121322_add_supporting_index_for_vulnerabilities_feedback_migration.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class AddSupportingIndexForVulnerabilitiesFeedbackMigration < Gitlab::Database::Migration[2.0]
+ INDEX_NAME = "tmp_idx_for_vulnerability_feedback_migration"
+ WHERE_CLAUSE = "migrated_to_state_transition = false AND feedback_type = 0"
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(
+ :vulnerability_feedback,
+ %i[migrated_to_state_transition feedback_type],
+ where: WHERE_CLAUSE,
+ name: INDEX_NAME
+ )
+ end
+
+ def down
+ remove_concurrent_index_by_name(
+ :vulnerability_feedback,
+ INDEX_NAME
+ )
+ end
+end
diff --git a/db/schema_migrations/20221102225800 b/db/schema_migrations/20221102225800
new file mode 100644
index 00000000000..fca933ed91b
--- /dev/null
+++ b/db/schema_migrations/20221102225800
@@ -0,0 +1 @@
+2e7e55a23574d45e877712fb67b2c2b50d85905c95fe4ec3990cfd8fe5160122 \ No newline at end of file
diff --git a/db/schema_migrations/20221108121322 b/db/schema_migrations/20221108121322
new file mode 100644
index 00000000000..d1880c9319a
--- /dev/null
+++ b/db/schema_migrations/20221108121322
@@ -0,0 +1 @@
+4e5deb2f5be081eef7b3dab726b2877bc21a7afad1b6a12aca240f510cada0b3 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index ac5c312c445..46d06cfb51a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -29137,6 +29137,8 @@ CREATE INDEX index_gitlab_subscriptions_on_end_date_and_namespace_id ON gitlab_s
CREATE INDEX index_gitlab_subscriptions_on_hosted_plan_id ON gitlab_subscriptions USING btree (hosted_plan_id);
+CREATE INDEX index_gitlab_subscriptions_on_max_seats_used_changed_at ON gitlab_subscriptions USING btree (max_seats_used_changed_at, namespace_id);
+
CREATE UNIQUE INDEX index_gitlab_subscriptions_on_namespace_id ON gitlab_subscriptions USING btree (namespace_id);
CREATE UNIQUE INDEX index_gpg_key_subkeys_on_fingerprint ON gpg_key_subkeys USING btree (fingerprint);
@@ -31221,6 +31223,8 @@ CREATE UNIQUE INDEX taggings_idx ON taggings USING btree (tag_id, taggable_id, t
CREATE UNIQUE INDEX term_agreements_unique_index ON term_agreements USING btree (user_id, term_id);
+CREATE INDEX tmp_idx_for_vulnerability_feedback_migration ON vulnerability_feedback USING btree (migrated_to_state_transition, feedback_type) WHERE ((migrated_to_state_transition = false) AND (feedback_type = 0));
+
CREATE INDEX tmp_idx_vulnerabilities_on_id_where_report_type_7_99 ON vulnerabilities USING btree (id) WHERE (report_type = ANY (ARRAY[7, 99]));
CREATE INDEX tmp_idx_where_user_details_fields_filled ON users USING btree (id) WHERE (((COALESCE(linkedin, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(twitter, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(skype, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(website_url, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(location, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(organization, ''::character varying))::text IS DISTINCT FROM ''::text));
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
index 22423188a12..9adfabb0ab2 100644
--- a/doc/administration/monitoring/prometheus/gitlab_metrics.md
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -44,6 +44,8 @@ The following metrics are available:
| `gitlab_ci_pipeline_size_builds` | Histogram | 13.1 | Total number of builds within a pipeline grouped by a pipeline source | `source` |
| `gitlab_ci_runner_authentication_success_total` | Counter | 15.2 | Total number of times that runner authentication has succeeded | `type` |
| `gitlab_ci_runner_authentication_failure_total` | Counter | 15.2 | Total number of times that runner authentication has failed
+| `gitlab_ghost_user_migration_lag_seconds` | Gauge | 15.6 | The waiting time in seconds of the oldest scheduled record for ghost user migration | |
+| `gitlab_ghost_user_migration_scheduled_records_total` | Gauge | 15.6 | The total number of scheduled ghost user migrations | |
| `job_waiter_started_total` | Counter | 12.9 | Number of batches of jobs started where a web request is waiting for the jobs to complete | `worker` |
| `job_waiter_timeouts_total` | Counter | 12.9 | Number of batches of jobs that timed out where a web request is waiting for the jobs to complete | `worker` |
| `gitlab_ci_active_jobs` | Histogram | 14.2 | Count of active jobs when pipeline is created | |
diff --git a/doc/administration/reference_architectures/10k_users.md b/doc/administration/reference_architectures/10k_users.md
index 588a00eb20c..c610640c46e 100644
--- a/doc/administration/reference_architectures/10k_users.md
+++ b/doc/administration/reference_architectures/10k_users.md
@@ -1853,7 +1853,7 @@ Updates to example must be made at:
gitlab_rails['auto_migrate'] = false
# Sidekiq
- sidekiqp['enable'] = true
+ sidekiq['enable'] = true
sidekiq['listen_address'] = "0.0.0.0"
# Set number of Sidekiq queue processes to the same number as available CPUs
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 87fc8d5a8cb..721b15db3d9 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -9942,6 +9942,36 @@ Represents the access level of a relationship between a User and object that it
| <a id="accesslevelintegervalue"></a>`integerValue` | [`Int`](#int) | Integer representation of access level. |
| <a id="accesslevelstringvalue"></a>`stringValue` | [`AccessLevelEnum`](#accesslevelenum) | String representation of access level. |
+### `AccessLevelGroup`
+
+Representation of a GitLab group.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="accesslevelgroupavatarurl"></a>`avatarUrl` | [`String`](#string) | Avatar URL of the group. |
+| <a id="accesslevelgroupid"></a>`id` | [`ID!`](#id) | ID of the group. |
+| <a id="accesslevelgroupname"></a>`name` | [`String!`](#string) | Name of the group. |
+| <a id="accesslevelgroupparent"></a>`parent` | [`AccessLevelGroup`](#accesslevelgroup) | Parent group. |
+| <a id="accesslevelgroupweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the group. |
+
+### `AccessLevelUser`
+
+Representation of a GitLab user.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="accessleveluseravatarurl"></a>`avatarUrl` | [`String`](#string) | URL of the user's avatar. |
+| <a id="accessleveluserid"></a>`id` | [`ID!`](#id) | ID of the user. |
+| <a id="accesslevelusername"></a>`name` | [`String!`](#string) | Human-readable name of the user. Returns `****` if the user is a project bot and the requester does not have permission to view the project. |
+| <a id="accessleveluserpublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
+| <a id="accessleveluserusername"></a>`username` | [`String!`](#string) | Username of the user. Unique within this instance of GitLab. |
+| <a id="accessleveluserwebpath"></a>`webPath` | [`String!`](#string) | Web path of the user. |
+| <a id="accessleveluserweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the user. |
+
### `AgentConfiguration`
Configuration details for an Agent.
@@ -10553,6 +10583,7 @@ List of branch rules for a project, grouped by branch name.
| <a id="branchrulecreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp of when the branch rule was created. |
| <a id="branchruleexternalstatuschecks"></a>`externalStatusChecks` | [`ExternalStatusCheckConnection`](#externalstatuscheckconnection) | External status checks configured for this branch rule. (see [Connections](#connections)) |
| <a id="branchruleisdefault"></a>`isDefault` | [`Boolean!`](#boolean) | Check if this branch rule protects the project's default branch. |
+| <a id="branchrulematchingbranchescount"></a>`matchingBranchesCount` | [`Int!`](#int) | Number of existing branches that match this branch rule. |
| <a id="branchrulename"></a>`name` | [`String!`](#string) | Branch name, with wildcards, for the branch rules. |
| <a id="branchruleupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of when the branch rule was last updated. |
@@ -14499,8 +14530,8 @@ Defines which user roles, users, or groups can merge into a protected branch.
| ---- | ---- | ----------- |
| <a id="mergeaccesslevelaccesslevel"></a>`accessLevel` | [`Int!`](#int) | GitLab::Access level. |
| <a id="mergeaccesslevelaccessleveldescription"></a>`accessLevelDescription` | [`String!`](#string) | Human readable representation for this access level. |
-| <a id="mergeaccesslevelgroup"></a>`group` | [`Group`](#group) | Group associated with this access level. |
-| <a id="mergeaccessleveluser"></a>`user` | [`UserCore`](#usercore) | User associated with this access level. |
+| <a id="mergeaccesslevelgroup"></a>`group` | [`AccessLevelGroup`](#accesslevelgroup) | Group associated with this access level. |
+| <a id="mergeaccessleveluser"></a>`user` | [`AccessLevelUser`](#accessleveluser) | User associated with this access level. |
### `MergeRequest`
@@ -18072,8 +18103,8 @@ Defines which user roles, users, or groups can push to a protected branch.
| ---- | ---- | ----------- |
| <a id="pushaccesslevelaccesslevel"></a>`accessLevel` | [`Int!`](#int) | GitLab::Access level. |
| <a id="pushaccesslevelaccessleveldescription"></a>`accessLevelDescription` | [`String!`](#string) | Human readable representation for this access level. |
-| <a id="pushaccesslevelgroup"></a>`group` | [`Group`](#group) | Group associated with this access level. |
-| <a id="pushaccessleveluser"></a>`user` | [`UserCore`](#usercore) | User associated with this access level. |
+| <a id="pushaccesslevelgroup"></a>`group` | [`AccessLevelGroup`](#accesslevelgroup) | Group associated with this access level. |
+| <a id="pushaccessleveluser"></a>`user` | [`AccessLevelUser`](#accessleveluser) | User associated with this access level. |
### `PushRules`
@@ -19327,8 +19358,8 @@ Defines which user roles, users, or groups can unprotect a protected branch.
| ---- | ---- | ----------- |
| <a id="unprotectaccesslevelaccesslevel"></a>`accessLevel` | [`Int!`](#int) | GitLab::Access level. |
| <a id="unprotectaccesslevelaccessleveldescription"></a>`accessLevelDescription` | [`String!`](#string) | Human readable representation for this access level. |
-| <a id="unprotectaccesslevelgroup"></a>`group` | [`Group`](#group) | Group associated with this access level. |
-| <a id="unprotectaccessleveluser"></a>`user` | [`UserCore`](#usercore) | User associated with this access level. |
+| <a id="unprotectaccesslevelgroup"></a>`group` | [`AccessLevelGroup`](#accesslevelgroup) | Group associated with this access level. |
+| <a id="unprotectaccessleveluser"></a>`user` | [`AccessLevelUser`](#accessleveluser) | User associated with this access level. |
### `UploadRegistry`
diff --git a/doc/api/suggestions.md b/doc/api/suggestions.md
index 9771225ad31..b3c18b82211 100644
--- a/doc/api/suggestions.md
+++ b/doc/api/suggestions.md
@@ -11,7 +11,7 @@ This page describes the API for [suggesting changes](../user/project/merge_reque
Every API call to suggestions must be authenticated.
-## Applying suggestions
+## Applying a suggestion
Applies a suggested patch in a merge request. Users must have
at least the Developer role to perform such action.
@@ -22,7 +22,7 @@ PUT /suggestions/:id/apply
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `id` | integer/string | yes | The ID of a suggestion |
+| `id` | integer | yes | The ID of a suggestion |
| `commit_message` | string | no | A custom commit message to use instead of the default generated message or the project's default message |
```shell
@@ -32,13 +32,53 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab
Example response:
```json
+{
+ "id": 5,
+ "from_line": 10,
+ "to_line": 10,
+ "applicable": true,
+ "applied": false,
+ "from_content": "This is an example\n",
+ "to_content": "This is an example\n"
+}
+```
+
+## Applying multiple suggestions
+
+```plaintext
+PUT /suggestions/batch_apply
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `ids` | integer | yes | The ID of a suggestion |
+| `commit_message` | string | no | A custom commit message to use instead of the default generated message or the project's default message |
+
+```shell
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --header 'Content-Type: application/json' --data '{"ids": [5, 6]}' "https://gitlab.example.com/api/v4/suggestions/batch_apply"
+```
+
+Example response:
+
+```json
+[
{
- "id": 36,
+ "id": 5,
"from_line": 10,
"to_line": 10,
- "applicable": false,
- "applied": true,
- "from_content": " \"--talk-name=org.freedesktop.\",\n",
- "to_content": " \"--talk-name=org.free.\",\n \"--talk-name=org.desktop.\",\n"
+ "applicable": true,
+ "applied": false,
+ "from_content": "This is an example\n",
+ "to_content": "This is an example\n"
+ }
+ {
+ "id": 6,
+ "from_line": 19
+ "to_line": 19,
+ "applicable": true,
+ "applied": false,
+ "from_content": "This is another eaxmple\n",
+ "to_content": "This is another example\n"
}
+ ]
```
diff --git a/doc/development/testing_guide/end_to_end/feature_flags.md b/doc/development/testing_guide/end_to_end/feature_flags.md
index 8e25c817938..6d826e170f6 100644
--- a/doc/development/testing_guide/end_to_end/feature_flags.md
+++ b/doc/development/testing_guide/end_to_end/feature_flags.md
@@ -160,7 +160,7 @@ For example:
```ruby
def initialize
- name_of_the_future_flag_activated = false
+ name_of_the_feature_flag_activated = false
...
end
```
diff --git a/doc/development/utilities.md b/doc/development/utilities.md
index 45a6b74f33a..7460a91f333 100644
--- a/doc/development/utilities.md
+++ b/doc/development/utilities.md
@@ -206,6 +206,22 @@ Refer to [`strong_memoize.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/maste
end
```
+ There's also `strong_memoize_with` to help memoize methods that take arguments.
+ This should be used for methods that have a low number of possible values
+ as arguments or with consistent repeating arguments in a loop.
+
+ ```ruby
+ class Find
+ include Gitlab::Utils::StrongMemoize
+
+ def result(basic: true)
+ strong_memoize_with(:result, basic) do
+ search(basic)
+ end
+ end
+ end
+ ```
+
- Clear memoization
```ruby
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index 55d1d1bcbb8..af039c8a009 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -43,23 +43,19 @@ GitLab supports the following OmniAuth providers.
Before you configure the OmniAuth provider,
configure the settings that are common for all providers.
-Setting | Description | Default value
----------------------------|-------------|--------------
-`allow_single_sign_on` | Enables you to list the providers that automatically create a GitLab account. The provider names are available in the **OmniAuth provider name** column in the [supported providers table](#supported-providers). | The default is `false`. If `false`, users must be created manually, or they can't sign in using OmniAuth.
-`auto_link_ldap_user` | If enabled, creates an LDAP identity in GitLab for users that are created through an OmniAuth provider. You can enable this setting if you have [LDAP integration](../administration/auth/ldap/index.md) enabled. Requires the `uid` of the user to be the same in both LDAP and the OmniAuth provider. | The default is `false`.
-`block_auto_created_users` | If enabled, blocks users that are automatically created from signing in until they are approved by an administrator. | The default is `true`. If you set the value to `false`, make sure you only define providers for `allow_single_sign_on` that you can control, like SAML or Google. Otherwise, any user on the internet can sign in to GitLab without an administrator's approval.
+Omnibus, Docker, and source | Helm chart | Description | Default value
+----------------------------|------------|-------------|-----------
+`allow_single_sign_on` | `allowSingleSignOn` | List of providers that automatically create a GitLab account. The provider names are available in the **OmniAuth provider name** column in the [supported providers table](#supported-providers). | `false`, which means that signing in using your OmniAuth provider account without a pre-existing GitLab account is not allowed. You must create a GitLab account first, and then connect it to your OmniAuth provider account through your profile settings.
+`auto_link_ldap_user` | `autoLinkLdapUser` | Creates an LDAP identity in GitLab for users that are created through an OmniAuth provider. You can enable this setting if you have [LDAP integration](../administration/auth/ldap/index.md) enabled. Requires the `uid` of the user to be the same in both LDAP and the OmniAuth provider. | `false`
+`block_auto_created_users` | `blockAutoCreatedUsers` | Blocks users that are automatically created from signing in until they are approved by an administrator. | `true`. If you set the value to `false`, make sure you define providers that you can control, like SAML or Google. Otherwise, any user on the internet can sign in to GitLab without an administrator's approval.
To change these settings:
-- **For Omnibus package**
-
- 1. Open the configuration file:
+ ::Tabs
- ```shell
- sudo editor /etc/gitlab/gitlab.rb
- ```
+ :::TabTitle Omnibus
- 1. Update the following section:
+ 1. Edit `/etc/gitlab/gitlab.rb` and update the following section:
```ruby
# CAUTION!
@@ -71,13 +67,47 @@ To change these settings:
gitlab_rails['omniauth_block_auto_created_users'] = true
```
-- **For installations from source**
+ 1. Reconfigure GitLab:
+
+ ```shell
+ sudo gitlab-ctl reconfigure
+ ```
+
+ :::TabTitle Helm chart
+
+ 1. Export the Helm values:
+
+ ```shell
+ helm get values gitlab > gitlab_values.yaml
+ ```
+
+ 1. Edit `gitlab_values.yaml`, and update the `omniauth` section under `globals.appConfig`:
+
+ ```yaml
+ global:
+ appConfig:
+ omniauth:
+ enabled: true
+ allowSingleSignOn: ['saml', 'twitter']
+ autoLinkLdapUser: false
+ blockAutoCreatedUsers: true
+ ```
+
+ For more details, see the
+ [globals documentation](https://docs.gitlab.com/charts/charts/globals.html#omniauth).
+
+ 1. Apply the new values:
+
+ ```shell
+ helm upgrade -f gitlab_values.yaml gitlab gitlab/gitlab
+ ```
+
+ :::TabTitle Source
1. Open the configuration file:
```shell
cd /home/git/gitlab
-
sudo -u git -H editor config/gitlab.yml
```
@@ -102,6 +132,14 @@ To change these settings:
block_auto_created_users: true
```
+ 1. Restart GitLab:
+
+ ```shell
+ sudo service gitlab restart
+ ```
+
+ ::EndTabs
+
After configuring these settings, you can configure
your chosen [provider](#supported-providers).
diff --git a/doc/update/index.md b/doc/update/index.md
index dd1896e02e6..a49ad5bc4ce 100644
--- a/doc/update/index.md
+++ b/doc/update/index.md
@@ -384,13 +384,15 @@ Find where your version sits in the upgrade path below, and upgrade GitLab
accordingly, while also consulting the
[version-specific upgrade instructions](#version-specific-upgrading-instructions):
-`8.11.Z` -> `8.12.0` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> [`11.11.8`](#1200) -> `12.0.12` -> [`12.1.17`](#1210) -> [`12.10.14`](#12100) -> `13.0.14` -> [`13.1.11`](#1310) -> [`13.8.8`](#1388) -> [`13.12.15`](#13120) -> [`14.0.12`](#1400) -> [`14.3.6`](#1430) -> [`14.9.5`](#1490) -> [`14.10.Z`](#14100) -> [`15.0.Z`](#1500) -> [`15.1.Z`](#1510)(for GitLab instances with multiple web nodes) -> [`15.4.0`](#1540) -> [latest `15.Y.Z`](https://gitlab.com/gitlab-org/gitlab/-/releases)
+`8.11.Z` -> `8.12.0` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> [`11.11.8`](#1200) -> `12.0.12` -> [`12.1.17`](#1210) -> [`12.10.14`](#12100) -> `13.0.14` -> [`13.1.11`](#1310) -> [`13.8.8`](#1388) -> [`13.12.15`](#13120) -> [`14.0.12`](#1400) -> [`14.3.6`](#1430) -> [`14.9.5`](#1490) -> [`14.10.Z`](#14100) -> [`15.0.Z`](#1500) -> [`15.1.Z`](#1510) (for GitLab instances with multiple web nodes) -> [`15.4.0`](#1540) -> [latest `15.Y.Z`](https://gitlab.com/gitlab-org/gitlab/-/releases)
NOTE:
When not explicitly specified, upgrade GitLab to the latest available patch
release rather than the first patch release, for example `13.8.8` instead of `13.8.0`.
This includes versions you must stop at on the upgrade path as there may
be fixes for issues relating to the upgrade process.
+Specifically around a [major version](#upgrading-to-a-new-major-version),
+crucial database schema and migration patches are included in the latest patch releases.
The following table, while not exhaustive, shows some examples of the supported
upgrade paths.
diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md
index 3a1f632ba11..5296a918f56 100644
--- a/doc/user/admin_area/license.md
+++ b/doc/user/admin_area/license.md
@@ -72,7 +72,7 @@ You may have connectivity issues due to the following reasons:
- Check if your GitLab instance has an encrypted connection to `customers.gitlab.com` (with IP addresses 172.64.146.11 and 104.18.41.245) on port 443:
```shell
- curl --verbose "telnet://customers.gitlab.com/"
+ curl --verbose "https://customers.gitlab.com/"
```
- If the curl command returns a failure, either:
diff --git a/doc/user/analytics/dora_metrics.md b/doc/user/analytics/dora_metrics.md
index 07b6d06f73e..a85cd25f712 100644
--- a/doc/user/analytics/dora_metrics.md
+++ b/doc/user/analytics/dora_metrics.md
@@ -52,8 +52,13 @@ To retrieve metrics for deployment frequency, use the [GraphQL](../../api/graphq
## Lead time for changes
-Lead time for changes measures the time to deliver a feature once it has been developed,
-as described in [Measuring DevOps Performance](https://devops.com/measuring-devops-performance/).
+DORA Lead time for changes measures the time to successfully deliver a commit into production.
+This metric reflects the efficiency of CI/CD pipelines.
+
+In GitLab, Lead time for changes calculates the median time it takes for a merge request to get merged into production.
+We measure **from** code committed **to** code successfully running in production, without adding the `coding_time` to the calculation.
+
+Over time, the lead time for changes should decrease, while your team's performance should increase.
Lead time for changes displays in several charts:
@@ -63,6 +68,9 @@ Lead time for changes displays in several charts:
To retrieve metrics for lead time for changes, use the [GraphQL](../../api/graphql/reference/index.md) or the [REST](../../api/dora/metrics.md) APIs.
+- The definition of lead time for change can vary widely, which often creates confusion within the industry.
+- "Lead time for changes" is not the same as "Lead time". In the value stream, "Lead time" measures the time it takes for work on an issue to move from the moment it's requested (Issue created) to the moment it's fulfilled and delivered (Issue closed).
+
## Time to restore service
Time to restore service measures how long it takes an organization to recover from a failure in production.
diff --git a/doc/user/project/merge_requests/changes.md b/doc/user/project/merge_requests/changes.md
index 6703cbf8b03..6e8b0cb1a75 100644
--- a/doc/user/project/merge_requests/changes.md
+++ b/doc/user/project/merge_requests/changes.md
@@ -149,7 +149,7 @@ The feature is not ready for production use.
To avoid displaying the changes that are already on target branch in the diff,
we compare the merge request's source branch with HEAD of the target branch.
-When there are conflicts between the source and target branch, we show the
-conflicts on the merge request diff:
+When there are conflicts between the source and target branch, we show an alert
+per conflicted file on the merge request diff:
-![Example of a conflict shown in a merge request diff](img/conflict_ui_v14_0.png)
+![Example of a conflict alert shown in a merge request diff](img/conflict_ui_v15_6.png)
diff --git a/doc/user/project/merge_requests/img/conflict_ui_v14_0.png b/doc/user/project/merge_requests/img/conflict_ui_v14_0.png
deleted file mode 100644
index 92c532351cb..00000000000
--- a/doc/user/project/merge_requests/img/conflict_ui_v14_0.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/merge_requests/img/conflict_ui_v15_6.png b/doc/user/project/merge_requests/img/conflict_ui_v15_6.png
new file mode 100644
index 00000000000..baa1cda3104
--- /dev/null
+++ b/doc/user/project/merge_requests/img/conflict_ui_v15_6.png
Binary files differ
diff --git a/doc/user/project/repository/img/web_editor_markdown_live_preview.png b/doc/user/project/repository/img/web_editor_markdown_live_preview.png
new file mode 100644
index 00000000000..43e62a36ff5
--- /dev/null
+++ b/doc/user/project/repository/img/web_editor_markdown_live_preview.png
Binary files differ
diff --git a/doc/user/project/repository/web_editor.md b/doc/user/project/repository/web_editor.md
index 98536322dc1..9bedea2cce4 100644
--- a/doc/user/project/repository/web_editor.md
+++ b/doc/user/project/repository/web_editor.md
@@ -55,6 +55,20 @@ NOTE:
The **Set up CI/CD** button does not appear on an empty repository. For the button
to display, add a file to your repository.
+## Preview Markdown
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/378966) in GitLab 15.6.
+
+To preview Markdown content in the Web Editor, select the **Preview** tab.
+In this tab, you can see a live Markdown preview that updates as you type alongside your content.
+
+![The Markdown Live Preview](img/web_editor_markdown_live_preview.png)
+
+To close the preview panel, do one of the following:
+
+- Select the **Write** tab.
+- From the context menu, select **Hide Live Preview**.
+
## Highlight lines
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56159) in GitLab 13.10 for GitLab SaaS instances.
diff --git a/doc/user/tasks.md b/doc/user/tasks.md
index bc83e09a052..5b8e824cb4c 100644
--- a/doc/user/tasks.md
+++ b/doc/user/tasks.md
@@ -159,6 +159,30 @@ To set a start date:
The due date must be the same or later than the start date.
If you select a start date to be later than the due date, the due date is then changed to the same day.
+## Add a task to a milestone
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/367463) in GitLab 15.5 [with a flag](../administration/feature_flags.md) named `work_items_mvc_2`. Disabled by default.
+
+FLAG:
+On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `work_items_mvc_2`. On GitLab.com, this feature is not available. The feature is not ready for production use.
+
+You can add a task to a [milestone](project/milestones/index.md).
+You can see the milestone title when you view a task.
+If you create a task for an issue that already belongs to a milestone,
+the new task inherits the milestone.
+
+Prerequisites:
+
+- You must have at least the Reporter role for the project.
+
+To add a task to a milestone:
+
+1. In the issue description, in the **Tasks** section, select the title of the task you want to edit.
+ The task window opens.
+1. Next to **Milestone**, select **Add to milestone**.
+If a task already belongs to a milestone, the dropdown list shows the current milestone.
+1. From the dropdown list, select the milestone to be associated with the task.
+
## Set task weight **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362550) in GitLab 15.3.
diff --git a/lib/api/api.rb b/lib/api/api.rb
index b1d359f0479..557721ecaf0 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -188,6 +188,7 @@ module API
mount ::API::Clusters::Agents
mount ::API::Commits
mount ::API::CommitStatuses
+ mount ::API::DependencyProxy
mount ::API::DeployKeys
mount ::API::DeployTokens
mount ::API::Deployments
@@ -226,6 +227,7 @@ module API
mount ::API::RemoteMirrors
mount ::API::Repositories
mount ::API::ResourceAccessTokens
+ mount ::API::ResourceMilestoneEvents
mount ::API::Snippets
mount ::API::SnippetRepositoryStorageMoves
mount ::API::Statistics
@@ -264,7 +266,6 @@ module API
mount ::API::ContainerRepositories
mount ::API::DebianGroupPackages
mount ::API::DebianProjectPackages
- mount ::API::DependencyProxy
mount ::API::Discussions
mount ::API::ErrorTracking::ClientKeys
mount ::API::ErrorTracking::Collector
@@ -314,7 +315,6 @@ module API
mount ::API::ProtectedTags
mount ::API::PypiPackages
mount ::API::ResourceLabelEvents
- mount ::API::ResourceMilestoneEvents
mount ::API::ResourceStateEvents
mount ::API::RpmProjectPackages
mount ::API::RubygemPackages
diff --git a/lib/api/dependency_proxy.rb b/lib/api/dependency_proxy.rb
index 290a90934d7..fcf18a2792a 100644
--- a/lib/api/dependency_proxy.rb
+++ b/lib/api/dependency_proxy.rb
@@ -12,11 +12,18 @@ module API
end
params do
- requires :id, type: String, desc: 'The ID of a group'
+ requires :id, types: [String, Integer],
+ desc: 'The ID or URL-encoded path of the group owned by the authenticated user'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
- desc 'Deletes all dependency_proxy_blobs for a group' do
- detail 'This feature was introduced in GitLab 12.10'
+ desc 'Purge the dependency proxy for a group' do
+ detail 'Schedules for deletion the cached manifests and blobs for a group.'\
+ 'This endpoint requires the Owner role for the group.'
+ success code: 202
+ failure [
+ { code: 401, message: 'Unauthorized' }
+ ]
+ tags %w[dependency_proxy]
end
delete ':id/dependency_proxy/cache' do
not_found! unless user_group.dependency_proxy_feature_available?
diff --git a/lib/api/entities/resource_milestone_event.rb b/lib/api/entities/resource_milestone_event.rb
index 26dc6620cbe..b301f5b7d0a 100644
--- a/lib/api/entities/resource_milestone_event.rb
+++ b/lib/api/entities/resource_milestone_event.rb
@@ -3,18 +3,18 @@
module API
module Entities
class ResourceMilestoneEvent < Grape::Entity
- expose :id
+ expose :id, documentation: { type: 'integer', example: 142 }
expose :user, using: Entities::UserBasic
- expose :created_at
- expose :resource_type do |event, _options|
+ expose :created_at, documentation: { type: 'dateTime', example: '2018-08-20T13:38:20.077Z' }
+ expose :resource_type, documentation: { type: 'string', example: 'Issue' } do |event, _options|
event.issuable.class.name
end
- expose :resource_id do |event, _options|
+ expose :resource_id, documentation: { type: 'integer', example: 253 } do |event, _options|
event.issuable.id
end
expose :milestone, using: Entities::Milestone
- expose :action
- expose :state
+ expose :action, documentation: { type: 'string', example: 'add' }
+ expose :state, documentation: { type: 'string', example: 'active' }
end
end
end
diff --git a/lib/api/resource_milestone_events.rb b/lib/api/resource_milestone_events.rb
index 04d71faa56a..5640e88ae6e 100644
--- a/lib/api/resource_milestone_events.rb
+++ b/lib/api/resource_milestone_events.rb
@@ -5,6 +5,8 @@ module API
include PaginationParams
helpers ::API::Helpers::NotesHelpers
+ resource_milestone_events_tags = %w[resource_milestone_events]
+
before { authenticate! }
{
@@ -15,17 +17,19 @@ module API
eventables_str = eventable_type.to_s.underscore.pluralize
params do
- requires :id, type: String, desc: "The ID of a #{parent_type}"
+ requires :id, types: [String, Integer], desc: "The ID or URL-encoded path of the #{parent_type}"
end
resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
- desc "Get a list of #{eventable_type.to_s.downcase} resource milestone events" do
+ desc "List project #{eventable_type.underscore.humanize} milestone events" do
+ detail "Gets a list of all milestone events for a single #{eventable_type.underscore.humanize}"
success Entities::ResourceMilestoneEvent
+ is_array true
+ tags resource_milestone_events_tags
end
params do
requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable'
use :pagination
end
-
get ":id/#{eventables_str}/:eventable_id/resource_milestone_events", feature_category: feature_category, urgency: :low do
eventable = find_noteable(eventable_type, params[:eventable_id])
@@ -34,8 +38,13 @@ module API
present paginate(events), with: Entities::ResourceMilestoneEvent
end
- desc "Get a single #{eventable_type.to_s.downcase} resource milestone event" do
+ desc "Get single #{eventable_type.underscore.humanize} milestone event" do
+ detail "Returns a single milestone event for a specific project #{eventable_type.underscore.humanize}"
success Entities::ResourceMilestoneEvent
+ failure [
+ { code: 404, message: 'Not found' }
+ ]
+ tags resource_milestone_events_tags
end
params do
requires :event_id, type: String, desc: 'The ID of a resource milestone event'
diff --git a/lib/api/terraform/modules/v1/packages.rb b/lib/api/terraform/modules/v1/packages.rb
index b850f47da20..79f98616073 100644
--- a/lib/api/terraform/modules/v1/packages.rb
+++ b/lib/api/terraform/modules/v1/packages.rb
@@ -192,7 +192,7 @@ module API
desc 'Download specific version of a module' do
detail 'Download specific version of a module'
- success code: 200, model: File
+ success File
failure [
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not found' }
diff --git a/lib/api/terraform/state_version.rb b/lib/api/terraform/state_version.rb
index 7e1f6349df5..f98aeb5860e 100644
--- a/lib/api/terraform/state_version.rb
+++ b/lib/api/terraform/state_version.rb
@@ -44,7 +44,7 @@ module API
desc 'Get a Terraform state version' do
detail 'Get a Terraform state version'
- success code: 200, model: File
+ success File
failure [
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not found' }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ec77229b29b..bba68acf262 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1108,6 +1108,11 @@ msgstr ""
msgid "%{text} is available"
msgstr ""
+msgid "%{thenLabelStart}Then%{thenLabelEnd} Require %{approvalsRequired} approval from %{approverType}%{approvers}"
+msgid_plural "%{thenLabelStart}Then%{thenLabelEnd} Require %{approvalsRequired} approvals from %{approverType}%{approvers}"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%{timebox_type} does not support burnup charts"
msgstr ""
@@ -18769,6 +18774,9 @@ msgstr ""
msgid "Go to your projects"
msgstr ""
+msgid "Go to your review requests"
+msgstr ""
+
msgid "Go to your snippets"
msgstr ""
@@ -34987,6 +34995,9 @@ msgstr ""
msgid "Runners|An error has occurred fetching instructions"
msgstr ""
+msgid "Runners|An error occurred while deleting. Some runners may not have been deleted."
+msgstr ""
+
msgid "Runners|An upgrade is available for this runner"
msgstr ""
@@ -35479,6 +35490,9 @@ msgstr ""
msgid "Runner|Owner"
msgstr ""
+msgid "Runner|Runner %{runnerName} failed to delete"
+msgstr ""
+
msgid "Running"
msgstr ""
@@ -36290,6 +36304,9 @@ msgstr ""
msgid "SecurityOrchestration|Choose a project"
msgstr ""
+msgid "SecurityOrchestration|Choose approver type"
+msgstr ""
+
msgid "SecurityOrchestration|Create more robust vulnerability rules and apply them to all your projects."
msgstr ""
@@ -36347,9 +36364,15 @@ msgstr ""
msgid "SecurityOrchestration|Failed to load vulnerability scanners."
msgstr ""
+msgid "SecurityOrchestration|Groups"
+msgstr ""
+
msgid "SecurityOrchestration|If any scanner finds a newly detected critical vulnerability in an open merge request targeting the master branch, then require two approvals from any member of App security."
msgstr ""
+msgid "SecurityOrchestration|Individual users"
+msgstr ""
+
msgid "SecurityOrchestration|Inherited"
msgstr ""
@@ -36488,6 +36511,9 @@ msgstr ""
msgid "SecurityOrchestration|Select security project"
msgstr ""
+msgid "SecurityOrchestration|Select users"
+msgstr ""
+
msgid "SecurityOrchestration|Something went wrong, unable to fetch policies"
msgstr ""
@@ -41548,7 +41574,7 @@ msgstr ""
msgid "This group"
msgstr ""
-msgid "This group and its subgroups and projects will be placed in a 'pending deletion' state for %{deletion_adjourned_period} days, then permanently deleted on %{date}. The group can be fully restored before that date."
+msgid "This group and its subgroups and projects will be placed in a 'pending deletion' state for %{deletion_delayed_period} days, then permanently deleted on %{date}. The group can be fully restored before that date."
msgstr ""
msgid "This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group."
@@ -48952,6 +48978,9 @@ msgstr ""
msgid "must be unique by status and elapsed time within a policy"
msgstr ""
+msgid "must belong to same project of its requirement object."
+msgstr ""
+
msgid "must belong to same project of the work item."
msgstr ""
diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
index 7c6b0d77219..686cc8fe11e 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/project/create_project_spec.rb
@@ -7,9 +7,6 @@ module QA
it 'creates a new project' do
Page::Project::Show.perform do |project_page|
expect(project_page).to have_content(project_name)
- expect(project_page).to have_content(
- /Project \S?#{project_name}\S+ was successfully created/
- )
expect(project_page).to have_content('The repository for this project is empty')
end
end
diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh
index a75ac4d1b8c..be27cf06b1b 100755
--- a/scripts/review_apps/review-apps.sh
+++ b/scripts/review_apps/review-apps.sh
@@ -367,28 +367,22 @@ EOF
}
function verify_deploy() {
- echoinfo "Verifying deployment at ${CI_ENVIRONMENT_URL}"
+ local namespace="${CI_ENVIRONMENT_SLUG}"
+
+ echoinfo "[$(date '+%H:%M:%S')] Verifying deployment at ${CI_ENVIRONMENT_URL}"
if retry "test_url \"${CI_ENVIRONMENT_URL}\""; then
- echoinfo "Review app is deployed to ${CI_ENVIRONMENT_URL}"
+ echoinfo "[$(date '+%H:%M:%S')] Review app is deployed to ${CI_ENVIRONMENT_URL}"
return 0
else
- echoerr "Review app is not available at ${CI_ENVIRONMENT_URL}: see the logs from cURL above for more details"
- echoerr "State of the pods:"
- kubectl get pods
+ echoerr "[$(date '+%H:%M:%S')] Review app is not available at ${CI_ENVIRONMENT_URL}: see the logs from cURL above for more details"
return 1
fi
}
function display_deployment_debug() {
local namespace="${CI_ENVIRONMENT_SLUG}"
- local release="${CI_ENVIRONMENT_SLUG}"
-
- # Get all pods for this release
- echoinfo "Pods for release ${release}"
- kubectl get pods --namespace "${namespace}" -lrelease=${release}
- # Get all non-completed jobs
- echoinfo "Unsuccessful Jobs for release ${release}"
- kubectl get jobs --namespace "${namespace}" -lrelease=${release} --field-selector=status.successful!=1
+ echoinfo "Environment debugging data:"
+ kubectl get svc,pods,jobs --namespace "${namespace}"
}
diff --git a/scripts/utils.sh b/scripts/utils.sh
index dae65ac8156..378f492a8bf 100644
--- a/scripts/utils.sh
+++ b/scripts/utils.sh
@@ -5,7 +5,7 @@ function retry() {
for i in 2 1; do
sleep 3s
- echo "Retrying $i..."
+ echo "[$(date '+%H:%M:%S')] Retrying $i..."
if eval "$@"; then
return 0
fi
diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
index 8dee0490fd6..044ce8f397a 100644
--- a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
+++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
RSpec.describe Profiles::PersonalAccessTokensController do
- let(:user) { create(:user) }
+ let(:access_token_user) { create(:user) }
let(:token_attributes) { attributes_for(:personal_access_token) }
before do
- sign_in(user)
+ sign_in(access_token_user)
end
describe '#create' do
@@ -49,13 +49,27 @@ RSpec.describe Profiles::PersonalAccessTokensController do
end
end
+ describe 'GET /-/profile/personal_access_tokens' do
+ let(:get_access_tokens) do
+ get :index
+ response
+ end
+
+ subject(:get_access_tokens_with_page) do
+ get :index, params: { page: 1 }
+ response
+ end
+
+ it_behaves_like 'GET access tokens are paginated and ordered'
+ end
+
describe '#index' do
- let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
+ let!(:active_personal_access_token) { create(:personal_access_token, user: access_token_user) }
before do
# Impersonation and inactive personal tokens are ignored
- create(:personal_access_token, :impersonation, user: user)
- create(:personal_access_token, :revoked, user: user)
+ create(:personal_access_token, :impersonation, user: access_token_user)
+ create(:personal_access_token, :revoked, user: access_token_user)
get :index
end
@@ -63,7 +77,7 @@ RSpec.describe Profiles::PersonalAccessTokensController do
active_personal_access_tokens_detail =
::PersonalAccessTokenSerializer.new.represent([active_personal_access_token])
- expect(assigns(:active_personal_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json)
+ expect(assigns(:active_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json)
end
it "sets PAT name and scopes" do
@@ -86,73 +100,10 @@ RSpec.describe Profiles::PersonalAccessTokensController do
expect(response).to have_gitlab_http_status(:not_found)
end
- context "access_token_pagination feature flag is enabled" do
- before do
- stub_feature_flags(access_token_pagination: true)
- allow(Kaminari.config).to receive(:default_per_page).and_return(1)
- create(:personal_access_token, user: user)
- end
-
- it "returns paginated response" do
- get :index, params: { page: 1 }
- expect(assigns(:active_personal_access_tokens).count).to eq(1)
- end
-
- it 'adds appropriate headers' do
- get :index, params: { page: 1 }
- expect_header('X-Per-Page', '1')
- expect_header('X-Page', '1')
- expect_header('X-Next-Page', '2')
- expect_header('X-Total', '2')
- end
- end
-
- context "tokens returned are ordered" do
- let(:expires_1_day_from_now) { 1.day.from_now.to_date }
- let(:expires_2_day_from_now) { 2.days.from_now.to_date }
-
- before do
- create(:personal_access_token, user: user, name: "Token1", expires_at: expires_1_day_from_now)
- create(:personal_access_token, user: user, name: "Token2", expires_at: expires_2_day_from_now)
- end
-
- it "orders token list ascending on expires_at" do
- get :index
-
- first_token = assigns(:active_personal_access_tokens).first.as_json
- expect(first_token['name']).to eq("Token1")
- expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
- end
-
- it "orders tokens on id in case token has same expires_at" do
- create(:personal_access_token, user: user, name: "Token3", expires_at: expires_1_day_from_now)
-
- get :index
-
- first_token = assigns(:active_personal_access_tokens).first.as_json
- expect(first_token['name']).to eq("Token3")
- expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
-
- second_token = assigns(:active_personal_access_tokens).second.as_json
- expect(second_token['name']).to eq("Token1")
- expect(second_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
- end
- end
-
- context "access_token_pagination feature flag is disabled" do
- before do
- stub_feature_flags(access_token_pagination: false)
- create(:personal_access_token, user: user)
- end
+ it 'returns tokens for json format' do
+ get :index, params: { format: :json }
- it "returns all tokens in system" do
- get :index, params: { page: 1 }
- expect(assigns(:active_personal_access_tokens).count).to eq(2)
- end
+ expect(json_response.count).to eq(1)
end
end
-
- def expect_header(header_name, header_val)
- expect(response.headers[header_name]).to eq(header_val)
- end
end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index b2051d9f3c6..b88d6b5fda4 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -24,6 +24,16 @@ FactoryBot.define do
project { pipeline.project }
+ trait :with_token do
+ transient do
+ generate_token { true }
+ end
+
+ after(:build) do |build, evaluator|
+ build.ensure_token if evaluator.generate_token
+ end
+ end
+
trait :degenerated do
options { nil }
yaml_variables { nil }
@@ -93,6 +103,7 @@ FactoryBot.define do
end
trait :pending do
+ with_token
queued_at { 'Di 29. Okt 09:50:59 CET 2013' }
status { 'pending' }
@@ -100,6 +111,7 @@ FactoryBot.define do
trait :created do
status { 'created' }
+ generate_token { false }
end
trait :preparing do
diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
index b97bf4a9cd8..64f5a0e3b57 100644
--- a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
+++ b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js
@@ -72,7 +72,6 @@ describe('RunnerBulkDelete', () => {
afterEach(() => {
bulkRunnerDeleteHandler.mockReset();
- wrapper.destroy();
});
describe('When no runners are checked', () => {
@@ -126,50 +125,61 @@ describe('RunnerBulkDelete', () => {
let evt;
let mockHideModal;
+ const confirmDeletion = () => {
+ evt = {
+ preventDefault: jest.fn(),
+ };
+ findModal().vm.$emit('primary', evt);
+ };
+
beforeEach(() => {
mockCheckedRunnerIds = [mockId1, mockId2];
createComponent();
jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {});
- mockHideModal = jest.spyOn(findModal().vm, 'hide');
+ mockHideModal = jest.spyOn(findModal().vm, 'hide').mockImplementation(() => {});
});
- describe('when deletion is successful', () => {
+ describe('when deletion is confirmed', () => {
beforeEach(() => {
- bulkRunnerDeleteHandler.mockResolvedValue({
- data: {
- bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] },
- },
- });
-
- evt = {
- preventDefault: jest.fn(),
- };
- findModal().vm.$emit('primary', evt);
+ confirmDeletion();
});
- it('has loading state', async () => {
+ it('has loading state', () => {
expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
expect(findModal().props('actionCancel').attributes.loading).toBe(true);
-
- await waitForPromises();
-
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
- expect(findModal().props('actionCancel').attributes.loading).toBe(false);
});
it('modal is not prevented from closing', () => {
expect(evt.preventDefault).toHaveBeenCalledTimes(1);
});
- it('mutation is called', async () => {
+ it('mutation is called', () => {
expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({
input: { ids: mockCheckedRunnerIds },
});
});
+ });
- it('user interface is updated', async () => {
+ describe('when deletion is successful', () => {
+ beforeEach(async () => {
+ bulkRunnerDeleteHandler.mockResolvedValue({
+ data: {
+ bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] },
+ },
+ });
+
+ confirmDeletion();
+ await waitForPromises();
+ });
+
+ it('removes loading state', () => {
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+ expect(findModal().props('actionCancel').attributes.loading).toBe(false);
+ });
+
+ it('user interface is updated', () => {
const { evict, gc } = apolloCache;
expect(evict).toHaveBeenCalledTimes(mockCheckedRunnerIds.length);
@@ -183,44 +193,80 @@ describe('RunnerBulkDelete', () => {
expect(gc).toHaveBeenCalledTimes(1);
});
+ it('emits deletion confirmation', () => {
+ expect(wrapper.emitted('deleted')).toEqual([
+ [{ message: expect.stringContaining(`${mockCheckedRunnerIds.length}`) }],
+ ]);
+ });
+
it('modal is hidden', () => {
expect(mockHideModal).toHaveBeenCalledTimes(1);
});
});
- describe('when deletion fails', () => {
- beforeEach(() => {
- bulkRunnerDeleteHandler.mockRejectedValue(new Error('error!'));
-
- evt = {
- preventDefault: jest.fn(),
- };
- findModal().vm.$emit('primary', evt);
- });
-
- it('has loading state', async () => {
- expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
- expect(findModal().props('actionCancel').attributes.loading).toBe(true);
+ describe('when deletion fails partially', () => {
+ beforeEach(async () => {
+ bulkRunnerDeleteHandler.mockResolvedValue({
+ data: {
+ bulkRunnerDelete: {
+ deletedIds: [mockId1], // only one runner could be deleted
+ errors: ['Can only delete up to 1 runners per call. Ignored 1 runner(s).'],
+ },
+ },
+ });
+ confirmDeletion();
await waitForPromises();
+ });
+ it('removes loading state', () => {
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
expect(findModal().props('actionCancel').attributes.loading).toBe(false);
});
- it('modal is not prevented from closing', () => {
- expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+ it('user interface is partially updated', () => {
+ const { evict, gc } = apolloCache;
+
+ expect(evict).toHaveBeenCalledTimes(1);
+ expect(evict).toHaveBeenCalledWith({
+ id: expect.stringContaining(mockId1),
+ });
+
+ expect(gc).toHaveBeenCalledTimes(1);
});
- it('mutation is called', () => {
- expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({
- input: { ids: mockCheckedRunnerIds },
+ it('emits deletion confirmation', () => {
+ expect(wrapper.emitted('deleted')).toEqual([[{ message: expect.stringContaining('1') }]]);
+ });
+
+ it('alert is called', () => {
+ expect(createAlert).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: expect.any(String),
+ captureError: true,
+ error: expect.any(Error),
});
});
- it('user interface is not updated', async () => {
+ it('modal is hidden', () => {
+ expect(mockHideModal).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when deletion fails', () => {
+ beforeEach(async () => {
+ bulkRunnerDeleteHandler.mockRejectedValue(new Error('error!'));
+
+ confirmDeletion();
await waitForPromises();
+ });
+ it('resolves loading state', () => {
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+ expect(findModal().props('actionCancel').attributes.loading).toBe(false);
+ });
+
+ it('user interface is not updated', () => {
const { evict, gc } = apolloCache;
expect(evict).not.toHaveBeenCalled();
@@ -228,9 +274,11 @@ describe('RunnerBulkDelete', () => {
expect(mockState.localMutations.clearChecked).not.toHaveBeenCalled();
});
- it('alert is called', async () => {
- await waitForPromises();
+ it('does not emit deletion confirmation', () => {
+ expect(wrapper.emitted('deleted')).toBeUndefined();
+ });
+ it('alert is called', () => {
expect(createAlert).toHaveBeenCalled();
expect(createAlert).toHaveBeenCalledWith({
message: expect.any(String),
@@ -238,6 +286,10 @@ describe('RunnerBulkDelete', () => {
error: expect.any(Error),
});
});
+
+ it('modal is hidden', () => {
+ expect(mockHideModal).toHaveBeenCalledTimes(1);
+ });
});
});
});
diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
index 44728c3170a..02960ad427e 100644
--- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
@@ -17,6 +17,7 @@ import { allRunnersData } from '../mock_data';
const mockRunner = allRunnersData.data.runners.nodes[0];
const mockRunnerId = getIdFromGraphQLId(mockRunner.id);
+const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`;
Vue.use(VueApollo);
@@ -96,7 +97,7 @@ describe('RunnerDeleteButton', () => {
});
it('Displays a modal with the runner name', () => {
- expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
+ expect(findModal().props('runnerName')).toBe(mockRunnerName);
});
it('Does not have tabindex when button is enabled', () => {
@@ -189,6 +190,10 @@ describe('RunnerDeleteButton', () => {
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ title: expect.stringContaining(mockRunnerName),
+ message: mockErrorMsg,
+ });
});
});
@@ -217,6 +222,10 @@ describe('RunnerDeleteButton', () => {
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ title: expect.stringContaining(mockRunnerName),
+ message: `${mockErrorMsg} ${mockErrorMsg2}`,
+ });
});
it('does not evict runner from apollo cache', () => {
diff --git a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js
index 864041141b8..c80e1184f02 100644
--- a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js
@@ -1,178 +1,34 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { resolvers } from '~/ci_variable_list/graphql/settings';
import ciAdminVariables from '~/ci_variable_list/components/ci_admin_variables.vue';
-import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
-import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
-import getAdminVariables from '~/ci_variable_list/graphql/queries/variables.query.graphql';
+import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue';
-import addAdminVariable from '~/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql';
-import deleteAdminVariable from '~/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql';
-import updateAdminVariable from '~/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql';
-
-import { genericMutationErrorText, variableFetchErrorText } from '~/ci_variable_list/constants';
-
-import { mockAdminVariables, newVariable } from '../mocks';
-
-jest.mock('~/flash');
-
-Vue.use(VueApollo);
-
-const mockProvide = {
- endpoint: '/variables',
-};
-
-describe('Ci Admin Variable list', () => {
+describe('Ci Project Variable wrapper', () => {
let wrapper;
- let mockApollo;
- let mockVariables;
-
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findCiTable = () => wrapper.findComponent(GlTable);
- const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
-
- // eslint-disable-next-line consistent-return
- const createComponentWithApollo = async ({ isLoading = false } = {}) => {
- const handlers = [[getAdminVariables, mockVariables]];
-
- mockApollo = createMockApollo(handlers, resolvers);
+ const findCiShared = () => wrapper.findComponent(ciVariableShared);
- wrapper = shallowMount(ciAdminVariables, {
- provide: mockProvide,
- apolloProvider: mockApollo,
- stubs: { ciVariableSettings, ciVariableTable },
- });
-
- if (!isLoading) {
- return waitForPromises();
- }
+ const createComponent = () => {
+ wrapper = shallowMount(ciAdminVariables);
};
beforeEach(() => {
- mockVariables = jest.fn();
+ createComponent();
});
afterEach(() => {
wrapper.destroy();
});
- describe('while queries are being fetch', () => {
- beforeEach(() => {
- createComponentWithApollo({ isLoading: true });
- });
-
- it('shows a loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findCiTable().exists()).toBe(false);
+ it('Passes down the correct props to ci_variable_shared', () => {
+ expect(findCiShared().props()).toEqual({
+ areScopedVariablesAvailable: false,
+ componentName: 'InstanceVariables',
+ mutationData: wrapper.vm.$options.mutationData,
+ queryData: wrapper.vm.$options.queryData,
+ refetchAfterMutation: true,
+ fullPath: null,
+ id: null,
});
});
-
- describe('when queries are resolved', () => {
- describe('successfuly', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockAdminVariables);
-
- await createComponentWithApollo();
- });
-
- it('passes down the expected environments as props', () => {
- expect(findCiSettings().props('environments')).toEqual([]);
- });
-
- it('passes down the expected variables as props', () => {
- expect(findCiSettings().props('variables')).toEqual(
- mockAdminVariables.data.ciVariables.nodes,
- );
- });
-
- it('createAlert was not called', () => {
- expect(createAlert).not.toHaveBeenCalled();
- });
- });
-
- describe('with an error for variables', () => {
- beforeEach(async () => {
- mockVariables.mockRejectedValue();
-
- await createComponentWithApollo();
- });
-
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
- });
- });
- });
-
- describe('mutations', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockAdminVariables);
-
- await createComponentWithApollo();
- });
- it.each`
- actionName | mutation | event
- ${'add'} | ${addAdminVariable} | ${'add-variable'}
- ${'update'} | ${updateAdminVariable} | ${'update-variable'}
- ${'delete'} | ${deleteAdminVariable} | ${'delete-variable'}
- `(
- 'calls the right mutation when user performs $actionName variable',
- async ({ event, mutation }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation,
- variables: {
- endpoint: mockProvide.endpoint,
- variable: newVariable,
- },
- });
- },
- );
-
- it.each`
- actionName | event | mutationName
- ${'add'} | ${'add-variable'} | ${'addAdminVariable'}
- ${'update'} | ${'update-variable'} | ${'updateAdminVariable'}
- ${'delete'} | ${'delete-variable'} | ${'deleteAdminVariable'}
- `(
- 'throws with the specific graphql error if present when user performs $actionName variable',
- async ({ event, mutationName }) => {
- const graphQLErrorMessage = 'There is a problem with this graphQL action';
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValue({ data: { [mutationName]: { errors: [graphQLErrorMessage] } } });
- await findCiSettings().vm.$emit(event, newVariable);
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
- },
- );
-
- it.each`
- actionName | event
- ${'add'} | ${'add-variable'}
- ${'update'} | ${'update-variable'}
- ${'delete'} | ${'delete-variable'}
- `(
- 'throws generic error when the mutation fails with no graphql errors and user performs $actionName variable',
- async ({ event }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
- throw new Error();
- });
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
- },
- );
- });
});
diff --git a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js
index 8a48e73eb9f..525cba3424b 100644
--- a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js
@@ -1,183 +1,71 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { resolvers } from '~/ci_variable_list/graphql/settings';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import ciGroupVariables from '~/ci_variable_list/components/ci_group_variables.vue';
-import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
-import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
-import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql';
+import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue';
-import addGroupVariable from '~/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql';
-import deleteGroupVariable from '~/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql';
-import updateGroupVariable from '~/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql';
-
-import { genericMutationErrorText, variableFetchErrorText } from '~/ci_variable_list/constants';
-
-import { mockGroupVariables, newVariable } from '../mocks';
-
-jest.mock('~/flash');
-
-Vue.use(VueApollo);
+import { GRAPHQL_GROUP_TYPE } from '~/ci_variable_list/constants';
const mockProvide = {
- endpoint: '/variables',
- groupPath: '/namespace/group',
- groupId: 1,
+ glFeatures: {
+ groupScopedCiVariables: false,
+ },
+ groupPath: '/group',
+ groupId: 12,
};
-describe('Ci Group Variable list', () => {
+describe('Ci Group Variable wrapper', () => {
let wrapper;
- let mockApollo;
- let mockVariables;
-
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findCiTable = () => wrapper.findComponent(GlTable);
- const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
-
- // eslint-disable-next-line consistent-return
- const createComponentWithApollo = async ({ isLoading = false } = {}) => {
- const handlers = [[getGroupVariables, mockVariables]];
-
- mockApollo = createMockApollo(handlers, resolvers);
+ const findCiShared = () => wrapper.findComponent(ciVariableShared);
+ const createComponent = ({ provide = {} } = {}) => {
wrapper = shallowMount(ciGroupVariables, {
- provide: mockProvide,
- apolloProvider: mockApollo,
- stubs: { ciVariableSettings, ciVariableTable },
+ provide: { ...mockProvide, ...provide },
});
-
- if (!isLoading) {
- return waitForPromises();
- }
};
- beforeEach(() => {
- mockVariables = jest.fn();
- });
-
afterEach(() => {
wrapper.destroy();
});
- describe('while queries are being fetch', () => {
+ describe('Props', () => {
beforeEach(() => {
- createComponentWithApollo({ isLoading: true });
+ createComponent();
});
- it('shows a loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findCiTable().exists()).toBe(false);
+ it('are passed down the correctly to ci_variable_shared', () => {
+ expect(findCiShared().props()).toEqual({
+ id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, mockProvide.groupId),
+ areScopedVariablesAvailable: false,
+ componentName: 'GroupVariables',
+ fullPath: mockProvide.groupPath,
+ mutationData: wrapper.vm.$options.mutationData,
+ queryData: wrapper.vm.$options.queryData,
+ refetchAfterMutation: false,
+ });
});
});
- describe('when queries are resolved', () => {
- describe('successfuly', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
-
- await createComponentWithApollo();
- });
-
- it('passes down the expected environments as props', () => {
- expect(findCiSettings().props('environments')).toEqual([]);
- });
-
- it('passes down the expected variables as props', () => {
- expect(findCiSettings().props('variables')).toEqual(
- mockGroupVariables.data.group.ciVariables.nodes,
- );
+ describe('feature flag', () => {
+ describe('When enabled', () => {
+ beforeEach(() => {
+ createComponent({ provide: { glFeatures: { groupScopedCiVariables: true } } });
});
- it('createAlert was not called', () => {
- expect(createAlert).not.toHaveBeenCalled();
+ it('Passes down `true` to variable shared component', () => {
+ expect(findCiShared().props('areScopedVariablesAvailable')).toBe(true);
});
});
- describe('with an error for variables', () => {
- beforeEach(async () => {
- mockVariables.mockRejectedValue();
-
- await createComponentWithApollo();
+ describe('When disabled', () => {
+ beforeEach(() => {
+ createComponent({ provide: { glFeatures: { groupScopedCiVariables: false } } });
});
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
+ it('Passes down `false` to variable shared component', () => {
+ expect(findCiShared().props('areScopedVariablesAvailable')).toBe(false);
});
});
});
-
- describe('mutations', () => {
- beforeEach(async () => {
- mockVariables.mockResolvedValue(mockGroupVariables);
-
- await createComponentWithApollo();
- });
- it.each`
- actionName | mutation | event
- ${'add'} | ${addGroupVariable} | ${'add-variable'}
- ${'update'} | ${updateGroupVariable} | ${'update-variable'}
- ${'delete'} | ${deleteGroupVariable} | ${'delete-variable'}
- `(
- 'calls the right mutation when user performs $actionName variable',
- async ({ event, mutation }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation,
- variables: {
- endpoint: mockProvide.endpoint,
- fullPath: mockProvide.groupPath,
- groupId: convertToGraphQLId('Group', mockProvide.groupId),
- variable: newVariable,
- },
- });
- },
- );
-
- it.each`
- actionName | event | mutationName
- ${'add'} | ${'add-variable'} | ${'addGroupVariable'}
- ${'update'} | ${'update-variable'} | ${'updateGroupVariable'}
- ${'delete'} | ${'delete-variable'} | ${'deleteGroupVariable'}
- `(
- 'throws with the specific graphql error if present when user performs $actionName variable',
- async ({ event, mutationName }) => {
- const graphQLErrorMessage = 'There is a problem with this graphQL action';
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValue({ data: { [mutationName]: { errors: [graphQLErrorMessage] } } });
- await findCiSettings().vm.$emit(event, newVariable);
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
- },
- );
-
- it.each`
- actionName | event
- ${'add'} | ${'add-variable'}
- ${'update'} | ${'update-variable'}
- ${'delete'} | ${'delete-variable'}
- `(
- 'throws generic error when the mutation fails with no graphql errors and user performs $actionName variable',
- async ({ event }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
- throw new Error();
- });
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
- },
- );
- });
});
diff --git a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js
index c630278fbde..984baa45d91 100644
--- a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js
@@ -1,215 +1,44 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
-import { resolvers } from '~/ci_variable_list/graphql/settings';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import ciProjectVariables from '~/ci_variable_list/components/ci_project_variables.vue';
-import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
-import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
-import getProjectEnvironments from '~/ci_variable_list/graphql/queries/project_environments.query.graphql';
-import getProjectVariables from '~/ci_variable_list/graphql/queries/project_variables.query.graphql';
+import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue';
-import addProjectVariable from '~/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql';
-import deleteProjectVariable from '~/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql';
-import updateProjectVariable from '~/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql';
-
-import {
- environmentFetchErrorText,
- genericMutationErrorText,
- variableFetchErrorText,
-} from '~/ci_variable_list/constants';
-
-import {
- devName,
- mockProjectEnvironments,
- mockProjectVariables,
- newVariable,
- prodName,
-} from '../mocks';
-
-jest.mock('~/flash');
-
-Vue.use(VueApollo);
+import { GRAPHQL_PROJECT_TYPE } from '~/ci_variable_list/constants';
const mockProvide = {
- endpoint: '/variables',
projectFullPath: '/namespace/project',
projectId: 1,
};
-describe('Ci Project Variable list', () => {
+describe('Ci Project Variable wrapper', () => {
let wrapper;
- let mockApollo;
- let mockEnvironments;
- let mockVariables;
-
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findCiTable = () => wrapper.findComponent(GlTable);
- const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
-
- // eslint-disable-next-line consistent-return
- const createComponentWithApollo = async ({ isLoading = false } = {}) => {
- const handlers = [
- [getProjectEnvironments, mockEnvironments],
- [getProjectVariables, mockVariables],
- ];
-
- mockApollo = createMockApollo(handlers, resolvers);
+ const findCiShared = () => wrapper.findComponent(ciVariableShared);
+ const createComponent = () => {
wrapper = shallowMount(ciProjectVariables, {
provide: mockProvide,
- apolloProvider: mockApollo,
- stubs: { ciVariableSettings, ciVariableTable },
});
-
- if (!isLoading) {
- return waitForPromises();
- }
};
beforeEach(() => {
- mockEnvironments = jest.fn();
- mockVariables = jest.fn();
+ createComponent();
});
afterEach(() => {
wrapper.destroy();
});
- describe('while queries are being fetch', () => {
- beforeEach(() => {
- createComponentWithApollo({ isLoading: true });
- });
-
- it('shows a loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findCiTable().exists()).toBe(false);
- });
- });
-
- describe('when queries are resolved', () => {
- describe('successfuly', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockResolvedValue(mockProjectVariables);
-
- await createComponentWithApollo();
- });
-
- it('passes down the expected environments as props', () => {
- expect(findCiSettings().props('environments')).toEqual([prodName, devName]);
- });
-
- it('passes down the expected variables as props', () => {
- expect(findCiSettings().props('variables')).toEqual(
- mockProjectVariables.data.project.ciVariables.nodes,
- );
- });
-
- it('createAlert was not called', () => {
- expect(createAlert).not.toHaveBeenCalled();
- });
- });
-
- describe('with an error for variables', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockRejectedValue();
-
- await createComponentWithApollo();
- });
-
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
- });
- });
-
- describe('with an error for environments', () => {
- beforeEach(async () => {
- mockEnvironments.mockRejectedValue();
- mockVariables.mockResolvedValue(mockProjectVariables);
-
- await createComponentWithApollo();
- });
-
- it('calls createAlert with the expected error message', () => {
- expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText });
- });
- });
- });
-
- describe('mutations', () => {
- beforeEach(async () => {
- mockEnvironments.mockResolvedValue(mockProjectEnvironments);
- mockVariables.mockResolvedValue(mockProjectVariables);
-
- await createComponentWithApollo();
+ it('Passes down the correct props to ci_variable_shared', () => {
+ expect(findCiShared().props()).toEqual({
+ id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, mockProvide.projectId),
+ areScopedVariablesAvailable: true,
+ componentName: 'ProjectVariables',
+ fullPath: mockProvide.projectFullPath,
+ mutationData: wrapper.vm.$options.mutationData,
+ queryData: wrapper.vm.$options.queryData,
+ refetchAfterMutation: false,
});
- it.each`
- actionName | mutation | event
- ${'add'} | ${addProjectVariable} | ${'add-variable'}
- ${'update'} | ${updateProjectVariable} | ${'update-variable'}
- ${'delete'} | ${deleteProjectVariable} | ${'delete-variable'}
- `(
- 'calls the right mutation when user performs $actionName variable',
- async ({ event, mutation }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation,
- variables: {
- endpoint: mockProvide.endpoint,
- fullPath: mockProvide.projectFullPath,
- projectId: convertToGraphQLId('Project', mockProvide.projectId),
- variable: newVariable,
- },
- });
- },
- );
-
- it.each`
- actionName | event | mutationName
- ${'add'} | ${'add-variable'} | ${'addProjectVariable'}
- ${'update'} | ${'update-variable'} | ${'updateProjectVariable'}
- ${'delete'} | ${'delete-variable'} | ${'deleteProjectVariable'}
- `(
- 'throws with the specific graphql error if present when user performs $actionName variable',
- async ({ event, mutationName }) => {
- const graphQLErrorMessage = 'There is a problem with this graphQL action';
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValue({ data: { [mutationName]: { errors: [graphQLErrorMessage] } } });
- await findCiSettings().vm.$emit(event, newVariable);
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
- },
- );
-
- it.each`
- actionName | event
- ${'add'} | ${'add-variable'}
- ${'update'} | ${'update-variable'}
- ${'delete'} | ${'delete-variable'}
- `(
- 'throws generic error when the mutation fails with no graphql errors and user performs $actionName variable',
- async ({ event }) => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
- throw new Error();
- });
- await findCiSettings().vm.$emit(event, newVariable);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
- expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
- },
- );
});
});
diff --git a/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js
new file mode 100644
index 00000000000..78c0bd2aa1f
--- /dev/null
+++ b/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js
@@ -0,0 +1,426 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/flash';
+import { resolvers } from '~/ci_variable_list/graphql/settings';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+
+import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue';
+import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
+import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
+import getProjectEnvironments from '~/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import getAdminVariables from '~/ci_variable_list/graphql/queries/variables.query.graphql';
+import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql';
+import getProjectVariables from '~/ci_variable_list/graphql/queries/project_variables.query.graphql';
+
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ environmentFetchErrorText,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '~/ci_variable_list/constants';
+
+import {
+ createGroupProps,
+ createInstanceProps,
+ createProjectProps,
+ devName,
+ mockProjectEnvironments,
+ mockProjectVariables,
+ newVariable,
+ prodName,
+ mockGroupVariables,
+ mockAdminVariables,
+} from '../mocks';
+
+jest.mock('~/flash');
+
+Vue.use(VueApollo);
+
+const mockProvide = {
+ endpoint: '/variables',
+};
+
+const defaultProps = {
+ areScopedVariablesAvailable: true,
+ refetchAfterMutation: false,
+};
+
+describe('Ci Variable Shared Component', () => {
+ let wrapper;
+
+ let mockApollo;
+ let mockEnvironments;
+ let mockVariables;
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findCiTable = () => wrapper.findComponent(GlTable);
+ const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
+
+ // eslint-disable-next-line consistent-return
+ async function createComponentWithApollo({
+ customHandlers = null,
+ isLoading = false,
+ props = { ...createProjectProps() },
+ } = {}) {
+ const handlers = customHandlers || [
+ [getProjectEnvironments, mockEnvironments],
+ [getProjectVariables, mockVariables],
+ ];
+
+ mockApollo = createMockApollo(handlers, resolvers);
+
+ wrapper = shallowMount(ciVariableShared, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: mockProvide,
+ apolloProvider: mockApollo,
+ stubs: { ciVariableSettings, ciVariableTable },
+ });
+
+ if (!isLoading) {
+ return waitForPromises();
+ }
+ }
+
+ beforeEach(() => {
+ mockEnvironments = jest.fn();
+ mockVariables = jest.fn();
+ });
+
+ describe('while queries are being fetch', () => {
+ beforeEach(() => {
+ createComponentWithApollo({ isLoading: true });
+ });
+
+ it('shows a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findCiTable().exists()).toBe(false);
+ });
+ });
+
+ describe('when queries are resolved', () => {
+ describe('successfuly', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
+ mockVariables.mockResolvedValue(mockProjectVariables);
+
+ await createComponentWithApollo();
+ });
+
+ it('passes down the expected environments as props', () => {
+ expect(findCiSettings().props('environments')).toEqual([prodName, devName]);
+ });
+
+ it('passes down the expected variables as props', () => {
+ expect(findCiSettings().props('variables')).toEqual(
+ mockProjectVariables.data.project.ciVariables.nodes,
+ );
+ });
+
+ it('createAlert was not called', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with an error for variables', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
+ mockVariables.mockRejectedValue();
+
+ await createComponentWithApollo();
+ });
+
+ it('calls createAlert with the expected error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText });
+ });
+ });
+
+ describe('with an error for environments', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockRejectedValue();
+ mockVariables.mockResolvedValue(mockProjectVariables);
+
+ await createComponentWithApollo();
+ });
+
+ it('calls createAlert with the expected error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText });
+ });
+ });
+ });
+
+ describe('environment query', () => {
+ describe('when there is an environment key in queryData', () => {
+ beforeEach(async () => {
+ mockEnvironments.mockResolvedValue(mockProjectEnvironments);
+ mockVariables.mockResolvedValue(mockProjectVariables);
+
+ await createComponentWithApollo({ props: { ...createProjectProps() } });
+ });
+
+ it('is executed', () => {
+ expect(mockVariables).toHaveBeenCalled();
+ });
+ });
+
+ describe('when there isnt an environment key in queryData', () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+
+ await createComponentWithApollo({ props: { ...createGroupProps() } });
+ });
+
+ it('is skipped', () => {
+ expect(mockVariables).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('mutations', () => {
+ const groupProps = createGroupProps();
+
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+
+ await createComponentWithApollo({
+ customHandlers: [[getGroupVariables, mockVariables]],
+ props: groupProps,
+ });
+ });
+ it.each`
+ actionName | mutation | event
+ ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'}
+ ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'}
+ ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'}
+ `(
+ 'calls the right mutation from propsData when user performs $actionName variable',
+ async ({ event, mutation }) => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
+
+ await findCiSettings().vm.$emit(event, newVariable);
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation,
+ variables: {
+ endpoint: mockProvide.endpoint,
+ fullPath: groupProps.fullPath,
+ id: convertToGraphQLId('Group', groupProps.id),
+ variable: newVariable,
+ },
+ });
+ },
+ );
+
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'throws with the specific graphql error if present when user performs $actionName variable',
+ async ({ event }) => {
+ const graphQLErrorMessage = 'There is a problem with this graphQL action';
+ jest
+ .spyOn(wrapper.vm.$apollo, 'mutate')
+ .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } });
+ await findCiSettings().vm.$emit(event, newVariable);
+ await nextTick();
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage });
+ },
+ );
+
+ it.each`
+ actionName | event
+ ${'add'} | ${'add-variable'}
+ ${'update'} | ${'update-variable'}
+ ${'delete'} | ${'delete-variable'}
+ `(
+ 'throws generic error on failure with no graphql errors and user performs $actionName variable',
+ async ({ event }) => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
+ throw new Error();
+ });
+ await findCiSettings().vm.$emit(event, newVariable);
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText });
+ },
+ );
+
+ describe('without fullpath and ID props', () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockAdminVariables);
+
+ await createComponentWithApollo({
+ customHandlers: [[getAdminVariables, mockVariables]],
+ props: createInstanceProps(),
+ });
+ });
+
+ it('does not pass fullPath and ID to the mutation', async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
+
+ await findCiSettings().vm.$emit('add-variable', newVariable);
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION],
+ variables: {
+ endpoint: mockProvide.endpoint,
+ variable: newVariable,
+ },
+ });
+ });
+ });
+ });
+
+ describe('Props', () => {
+ describe('in a specific context as', () => {
+ it.each`
+ name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | mutation
+ ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${null}
+ ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${getGroupVariables}
+ ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${getAdminVariables}
+ `(
+ 'passes down all the required props when its a $name component',
+ async ({
+ mutation,
+ mockVariablesValue,
+ mockEnvironmentsValue,
+ withEnvironments,
+ expectedEnvironments,
+ propsFn,
+ }) => {
+ const props = propsFn();
+
+ mockVariables.mockResolvedValue(mockVariablesValue);
+
+ if (withEnvironments) {
+ mockEnvironments.mockResolvedValue(mockEnvironmentsValue);
+ }
+
+ let customHandlers = null;
+
+ if (mutation) {
+ customHandlers = [[mutation, mockVariables]];
+ }
+
+ await createComponentWithApollo({ customHandlers, props });
+
+ expect(findCiSettings().props()).toEqual({
+ areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable,
+ isLoading: false,
+ variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes,
+ environments: expectedEnvironments,
+ });
+ },
+ );
+ });
+
+ describe('refetchAfterMutation', () => {
+ it.each`
+ bool | text
+ ${true} | ${'refetches the variables'}
+ ${false} | ${'does not refetch the variables'}
+ `('when $bool it $text', async ({ bool }) => {
+ await createComponentWithApollo({
+ props: { ...createInstanceProps(), refetchAfterMutation: bool },
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} });
+ jest.spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch').mockImplementation(jest.fn());
+
+ await findCiSettings().vm.$emit('add-variable', newVariable);
+
+ await nextTick();
+
+ if (bool) {
+ expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled();
+ } else {
+ expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('Validators', () => {
+ describe('queryData', () => {
+ let error;
+
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+ });
+
+ it('will mount component with right data', async () => {
+ try {
+ await createComponentWithApollo({
+ customHandlers: [[getGroupVariables, mockVariables]],
+ props: { ...createGroupProps() },
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(true);
+ expect(error).toBeUndefined();
+ }
+ });
+
+ it('will not mount component with wrong data', async () => {
+ try {
+ await createComponentWithApollo({
+ customHandlers: [[getGroupVariables, mockVariables]],
+ props: { ...createGroupProps(), queryData: { wrongKey: {} } },
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(false);
+ expect(error.toString()).toContain('custom validator check failed for prop');
+ }
+ });
+ });
+
+ describe('mutationData', () => {
+ let error;
+
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockGroupVariables);
+ });
+
+ it('will mount component with right data', async () => {
+ try {
+ await createComponentWithApollo({
+ props: { ...createGroupProps() },
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(true);
+ expect(error).toBeUndefined();
+ }
+ });
+
+ it('will not mount component with wrong data', async () => {
+ try {
+ await createComponentWithApollo({
+ props: { ...createGroupProps(), mutationData: { wrongKey: {} } },
+ });
+ } catch (e) {
+ error = e;
+ } finally {
+ expect(wrapper.exists()).toBe(false);
+ expect(error.toString()).toContain('custom validator check failed for prop');
+ }
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/mocks.js b/spec/frontend/ci_variable_list/mocks.js
index 6f3e73f8b83..03b77f80430 100644
--- a/spec/frontend/ci_variable_list/mocks.js
+++ b/spec/frontend/ci_variable_list/mocks.js
@@ -1,10 +1,28 @@
import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
variableTypes,
groupString,
instanceString,
projectString,
} from '~/ci_variable_list/constants';
+import addAdminVariable from '~/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql';
+import deleteAdminVariable from '~/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql';
+import updateAdminVariable from '~/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql';
+import addGroupVariable from '~/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql';
+import deleteGroupVariable from '~/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql';
+import updateGroupVariable from '~/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql';
+import addProjectVariable from '~/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql';
+import deleteProjectVariable from '~/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql';
+import updateProjectVariable from '~/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql';
+
+import getAdminVariables from '~/ci_variable_list/graphql/queries/variables.query.graphql';
+import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql';
+import getProjectEnvironments from '~/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '~/ci_variable_list/graphql/queries/project_variables.query.graphql';
+
export const devName = 'dev';
export const prodName = 'prod';
@@ -118,3 +136,62 @@ export const newVariable = {
value: 'devops',
variableType: variableTypes.variableType,
};
+
+export const createProjectProps = () => {
+ return {
+ componentName: 'ProjectVariable',
+ fullPath: '/namespace/project/',
+ id: 'gid://gitlab/Project/20',
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addProjectVariable,
+ [UPDATE_MUTATION_ACTION]: updateProjectVariable,
+ [DELETE_MUTATION_ACTION]: deleteProjectVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.project?.ciVariables,
+ query: getProjectVariables,
+ },
+ environments: {
+ lookup: (data) => data?.project?.environments,
+ query: getProjectEnvironments,
+ },
+ },
+ };
+};
+
+export const createGroupProps = () => {
+ return {
+ componentName: 'GroupVariable',
+ fullPath: '/my-group',
+ id: 'gid://gitlab/Group/20',
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addGroupVariable,
+ [UPDATE_MUTATION_ACTION]: updateGroupVariable,
+ [DELETE_MUTATION_ACTION]: deleteGroupVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.group?.ciVariables,
+ query: getGroupVariables,
+ },
+ },
+ };
+};
+
+export const createInstanceProps = () => {
+ return {
+ componentName: 'InstanceVariable',
+ mutationData: {
+ [ADD_MUTATION_ACTION]: addAdminVariable,
+ [UPDATE_MUTATION_ACTION]: updateAdminVariable,
+ [DELETE_MUTATION_ACTION]: deleteAdminVariable,
+ },
+ queryData: {
+ ciVariables: {
+ lookup: (data) => data?.ciVariables,
+ query: getAdminVariables,
+ },
+ },
+ };
+};
diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js
index 6e2908e659f..2eed1e30d0d 100644
--- a/spec/frontend/token_access/mock_data.js
+++ b/spec/frontend/token_access/mock_data.js
@@ -38,6 +38,10 @@ export const projectsWithScope = {
id: '2',
fullPath: 'root/332268-test',
name: 'root/332268-test',
+ namespace: {
+ id: '1234',
+ fullPath: 'root',
+ },
},
],
},
@@ -68,6 +72,10 @@ export const mockProjects = [
{
id: '1',
name: 'merge-train-stuff',
+ namespace: {
+ id: '1235',
+ fullPath: 'root',
+ },
fullPath: 'root/merge-train-stuff',
isLocked: false,
__typename: 'Project',
@@ -75,6 +83,10 @@ export const mockProjects = [
{
id: '2',
name: 'ci-project',
+ namespace: {
+ id: '1236',
+ fullPath: 'root',
+ },
fullPath: 'root/ci-project',
isLocked: true,
__typename: 'Project',
diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js
index c55ac32b6a6..ea1d9db515a 100644
--- a/spec/frontend/token_access/token_access_spec.js
+++ b/spec/frontend/token_access/token_access_spec.js
@@ -19,7 +19,8 @@ import {
} from './mock_data';
const projectPath = 'root/my-repo';
-const error = new Error('Error');
+const message = 'An error occurred';
+const error = new Error(message);
Vue.use(VueApollo);
@@ -144,7 +145,7 @@ describe('TokenAccess component', () => {
await waitForPromises();
- expect(createAlert).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message });
});
});
@@ -187,7 +188,7 @@ describe('TokenAccess component', () => {
await waitForPromises();
- expect(createAlert).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message });
});
});
});
diff --git a/spec/frontend/token_access/token_projects_table_spec.js b/spec/frontend/token_access/token_projects_table_spec.js
index 3bda0d0b530..0fa1a2453f7 100644
--- a/spec/frontend/token_access/token_projects_table_spec.js
+++ b/spec/frontend/token_access/token_projects_table_spec.js
@@ -1,5 +1,5 @@
import { GlTable, GlButton } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import TokenProjectsTable from '~/token_access/components/token_projects_table.vue';
import { mockProjects } from './mock_data';
@@ -7,7 +7,7 @@ describe('Token projects table', () => {
let wrapper;
const createComponent = () => {
- wrapper = mount(TokenProjectsTable, {
+ wrapper = mountExtended(TokenProjectsTable, {
provide: {
fullPath: 'root/ci-project',
},
@@ -18,9 +18,11 @@ describe('Token projects table', () => {
};
const findTable = () => wrapper.findComponent(GlTable);
- const findAllTableRows = () => wrapper.findAll('[data-testid="projects-token-table-row"]');
const findDeleteProjectBtn = () => wrapper.findComponent(GlButton);
const findAllDeleteProjectBtn = () => wrapper.findAllComponents(GlButton);
+ const findAllTableRows = () => wrapper.findAllByTestId('projects-token-table-row');
+ const findProjectNameCell = () => wrapper.findByTestId('token-access-project-name');
+ const findProjectNamespaceCell = () => wrapper.findByTestId('token-access-project-namespace');
beforeEach(() => {
createComponent();
@@ -48,4 +50,9 @@ describe('Token projects table', () => {
// currently two mock projects with one being a locked project
expect(findAllDeleteProjectBtn()).toHaveLength(1);
});
+
+ it('displays project and namespace cells', () => {
+ expect(findProjectNameCell().text()).toBe('merge-train-stuff');
+ expect(findProjectNamespaceCell().text()).toBe('root');
+ });
});
diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js
index 6fd5ae0e946..77c03dc0c3c 100644
--- a/spec/frontend/vue_shared/components/help_popover_spec.js
+++ b/spec/frontend/vue_shared/components/help_popover_spec.js
@@ -96,6 +96,20 @@ describe('HelpPopover', () => {
});
});
+ describe('with alternative icon', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ icon: 'information-o',
+ },
+ });
+ });
+
+ it('uses the given icon', () => {
+ expect(findQuestionButton().props('icon')).toBe('information-o');
+ });
+ });
+
describe('with custom slots', () => {
const titleSlot = '<h1>title</h1>';
const defaultSlot = '<strong>content</strong>';
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
index bc6df1a2565..8d072c8c8de 100644
--- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
@@ -8,13 +8,14 @@ describe('Highlight.js plugin for wrapping _emitter nodes', () => {
children: [
{ kind: 'string', children: ['Text 1'] },
{ kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] },
+ { kind: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] },
'Text4\nText5',
],
},
},
};
- const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text4</span>\n<span class="">Text5</span>`;
+ const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text 3 (sublanguage)</span><span class="">Text4</span>\n<span class="">Text5</span>`;
wrapChildNodes(hljsResultMock);
expect(hljsResultMock.value).toBe(outputValue);
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 6dbca7086cc..26777b57797 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -28,9 +28,9 @@ import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.grap
import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
+import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
-import { config } from '~/graphql_shared/issuable_client';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
mockParent,
@@ -38,6 +38,7 @@ import {
workItemResponseFactory,
workItemTitleSubscriptionResponse,
workItemAssigneesSubscriptionResponse,
+ workItemMilestoneSubscriptionResponse,
projectWorkItemResponse,
} from '../mock_data';
@@ -57,6 +58,9 @@ describe('WorkItemDetail component', () => {
const successByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse);
const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
+ const milestoneSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemMilestoneSubscriptionResponse);
const assigneesSubscriptionHandler = jest
.fn()
.mockResolvedValue(workItemAssigneesSubscriptionResponse);
@@ -88,7 +92,6 @@ describe('WorkItemDetail component', () => {
subscriptionHandler = titleSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
error = undefined,
- includeWidgets = false,
workItemsMvc2Enabled = false,
fetchByIid = false,
iidPathQueryParam = undefined,
@@ -98,18 +101,13 @@ describe('WorkItemDetail component', () => {
[workItemTitleSubscription, subscriptionHandler],
[workItemDatesSubscription, datesSubscriptionHandler],
[workItemAssigneesSubscription, assigneesSubscriptionHandler],
+ [workItemMilestoneSubscription, milestoneSubscriptionHandler],
[workItemByIidQuery, successByIidHandler],
confidentialityMock,
];
wrapper = shallowMount(WorkItemDetail, {
- apolloProvider: createMockApollo(
- handlers,
- {},
- {
- typePolicies: includeWidgets ? config.cacheConfig.typePolicies : {},
- },
- ),
+ apolloProvider: createMockApollo(handlers),
propsData: { isModal, workItemId, iid: '1' },
data() {
return {
@@ -559,15 +557,41 @@ describe('WorkItemDetail component', () => {
describe('milestone widget', () => {
it.each`
- description | includeWidgets | exists
- ${'renders when widget is returned from API'} | ${true} | ${true}
- ${'does not render when widget is not returned from API'} | ${false} | ${false}
- `('$description', async ({ includeWidgets, exists }) => {
- createComponent({ includeWidgets, workItemsMvc2Enabled: true });
+ description | milestoneWidgetPresent | exists
+ ${'renders when widget is returned from API'} | ${true} | ${true}
+ ${'does not render when widget is not returned from API'} | ${false} | ${false}
+ `('$description', async ({ milestoneWidgetPresent, exists }) => {
+ const response = workItemResponseFactory({ milestoneWidgetPresent });
+ const handler = jest.fn().mockResolvedValue(response);
+ createComponent({ handler, workItemsMvc2Enabled: true });
await waitForPromises();
expect(findWorkItemMilestone().exists()).toBe(exists);
});
+
+ describe('milestone subscription', () => {
+ describe('when the milestone widget exists', () => {
+ it('calls the milestone subscription', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(milestoneSubscriptionHandler).toHaveBeenCalledWith({
+ issuableId: workItemQueryResponse.data.workItem.id,
+ });
+ });
+ });
+
+ describe('when the assignees widget does not exist', () => {
+ it('does not call the milestone subscription', async () => {
+ const response = workItemResponseFactory({ milestoneWidgetPresent: false });
+ const handler = jest.fn().mockResolvedValue(response);
+ createComponent({ handler });
+ await waitForPromises();
+
+ expect(milestoneSubscriptionHandler).not.toHaveBeenCalled();
+ });
+ });
+ });
});
describe('work item information', () => {
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
index c790309fde5..60866aa98b1 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -93,7 +93,7 @@ describe('WorkItemLinksForm', () => {
});
it('creates child task in confidential parent', async () => {
- await createComponent({ parentConfidential: true });
+ await createComponent({ parentConfidential: true, workItemsMvc2Enabled: true });
findInput().vm.$emit('input', 'Create confidential task');
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index 6961996f912..30ccd68d276 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -41,6 +41,13 @@ const issueDetailsResponse = (confidential = false) => ({
},
__typename: 'Iteration',
},
+ milestone: {
+ dueDate: null,
+ expired: false,
+ id: 'gid://gitlab/Milestone/28',
+ title: 'v2.0',
+ __typename: 'Milestone',
+ },
__typename: 'Issue',
},
__typename: 'Project',
diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js
index e5713efdbec..60ba2b55f76 100644
--- a/spec/frontend/work_items/components/work_item_milestone_spec.js
+++ b/spec/frontend/work_items/components/work_item_milestone_spec.js
@@ -22,8 +22,14 @@ import {
mockMilestoneWidgetResponse,
workItemResponseFactory,
updateWorkItemMutationErrorResponse,
+ workItemMilestoneSubscriptionResponse,
+ projectWorkItemResponse,
+ updateWorkItemMutationResponse,
} from 'jest/work_items/mock_data';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
describe('WorkItemMilestone component', () => {
Vue.use(VueApollo);
@@ -47,6 +53,8 @@ describe('WorkItemMilestone component', () => {
const findInputGroup = () => wrapper.findComponent(GlFormGroup);
const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
+ const workItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
+ const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse);
const networkResolvedValue = new Error();
@@ -54,6 +62,12 @@ describe('WorkItemMilestone component', () => {
const successSearchWithNoMatchingMilestones = jest
.fn()
.mockResolvedValue(projectMilestonesResponseWithNoMilestones);
+ const milestoneSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemMilestoneSubscriptionResponse);
+ const successUpdateWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponse);
const showDropdown = () => {
findDropdown().vm.$emit('shown');
@@ -67,9 +81,17 @@ describe('WorkItemMilestone component', () => {
canUpdate = true,
milestone = mockMilestoneWidgetResponse,
searchQueryHandler = successSearchQueryHandler,
+ fetchByIid = false,
+ mutationHandler = successUpdateWorkItemMutationHandler,
} = {}) => {
const apolloProvider = createMockApollo(
- [[projectMilestonesQuery, searchQueryHandler]],
+ [
+ [workItemQuery, workItemQueryHandler],
+ [workItemMilestoneSubscription, milestoneSubscriptionHandler],
+ [projectMilestonesQuery, searchQueryHandler],
+ [updateWorkItemMutation, mutationHandler],
+ [workItemByIidQuery, workItemByIidResponseHandler],
+ ],
resolvers,
{
typePolicies: config.cacheConfig.typePolicies,
@@ -92,6 +114,10 @@ describe('WorkItemMilestone component', () => {
workItemId,
workItemType,
fullPath,
+ queryVariables: {
+ id: workItemId,
+ },
+ fetchByIid,
},
stubs: {
GlDropdown,
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index b66e250f428..0099dce77b3 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -301,11 +301,12 @@ export const workItemResponseFactory = ({
milestoneWidgetPresent
? {
__typename: 'WorkItemWidgetMilestone',
- dueDate: null,
- expired: false,
- id: 'gid://gitlab/Milestone/30',
- title: 'v4.0',
type: 'MILESTONE',
+ milestone: {
+ expired: false,
+ id: 'gid://gitlab/Milestone/30',
+ title: 'v4.0',
+ },
}
: { type: 'MOCK TYPE' },
{
@@ -610,6 +611,25 @@ export const workItemIterationSubscriptionResponse = {
},
};
+export const workItemMilestoneSubscriptionResponse = {
+ data: {
+ issuableMilestoneUpdated: {
+ id: 'gid://gitlab/WorkItem/1',
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetMilestone',
+ type: 'MILESTONE',
+ milestone: {
+ id: 'gid://gitlab/Milestone/1125',
+ expired: false,
+ title: 'Milestone title',
+ },
+ },
+ ],
+ },
+ },
+};
+
export const workItemHierarchyEmptyResponse = {
data: {
workItem: {
@@ -1116,7 +1136,7 @@ export const groupIterationsResponseWithNoIterations = {
};
export const mockMilestoneWidgetResponse = {
- dueDate: null,
+ state: 'active',
expired: false,
id: 'gid://gitlab/Milestone/30',
title: 'v4.0',
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index eb81c38f889..982f9f71f9e 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -10,6 +10,8 @@ import {
workItemTitleSubscriptionResponse,
workItemWeightSubscriptionResponse,
workItemLabelsSubscriptionResponse,
+ workItemMilestoneSubscriptionResponse,
+ workItemDescriptionSubscriptionResponse,
} from 'jest/work_items/mock_data';
import App from '~/work_items/components/app.vue';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
@@ -17,6 +19,8 @@ import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subs
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
+import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
+import workItemDescriptionSubscription from '~/work_items/graphql/work_item_description.subscription.graphql';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
import { createRouter } from '~/work_items/router';
@@ -34,6 +38,12 @@ describe('Work items router', () => {
.fn()
.mockResolvedValue(workItemAssigneesSubscriptionResponse);
const labelsSubscriptionHandler = jest.fn().mockResolvedValue(workItemLabelsSubscriptionResponse);
+ const milestoneSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemMilestoneSubscriptionResponse);
+ const descriptionSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(workItemDescriptionSubscriptionResponse);
const createComponent = async (routeArg) => {
const router = createRouter('/work_item');
@@ -47,6 +57,8 @@ describe('Work items router', () => {
[workItemTitleSubscription, titleSubscriptionHandler],
[workItemAssigneesSubscription, assigneesSubscriptionHandler],
[workItemLabelsSubscription, labelsSubscriptionHandler],
+ [workItemMilestoneSubscription, milestoneSubscriptionHandler],
+ [workItemDescriptionSubscription, descriptionSubscriptionHandler],
];
if (IS_EE) {
@@ -59,6 +71,7 @@ describe('Work items router', () => {
provide: {
fullPath: 'full-path',
issuesListPath: 'full-path/-/issues',
+ hasIssueWeightsFeature: false,
},
});
};
diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb
index 8c1cfab081b..561d165148b 100644
--- a/spec/graphql/types/commit_type_spec.rb
+++ b/spec/graphql/types/commit_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Commit'] do
specify { expect(described_class.graphql_name).to eq('Commit') }
- specify { expect(described_class).to require_graphql_authorizations(:download_code) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_code) }
specify { expect(described_class).to include(Types::TodoableInterface) }
diff --git a/spec/graphql/types/projects/branch_rule_type_spec.rb b/spec/graphql/types/projects/branch_rule_type_spec.rb
index 41923ebb6a9..54ea4f6857b 100644
--- a/spec/graphql/types/projects/branch_rule_type_spec.rb
+++ b/spec/graphql/types/projects/branch_rule_type_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe GitlabSchema.types['BranchRule'] do
name
isDefault
branch_protection
+ matching_branches_count
created_at
updated_at
]
diff --git a/spec/graphql/types/release_links_type_spec.rb b/spec/graphql/types/release_links_type_spec.rb
index e77c4e3ddd1..5a29050a4a2 100644
--- a/spec/graphql/types/release_links_type_spec.rb
+++ b/spec/graphql/types/release_links_type_spec.rb
@@ -26,31 +26,31 @@ RSpec.describe GitlabSchema.types['ReleaseLinks'] do
describe 'openedMergeRequestsUrl' do
it 'has valid authorization' do
- expect(fetch_authorizations('openedMergeRequestsUrl')).to include(:download_code)
+ expect(fetch_authorizations('openedMergeRequestsUrl')).to include(:read_code)
end
end
describe 'mergedMergeRequestsUrl' do
it 'has valid authorization' do
- expect(fetch_authorizations('mergedMergeRequestsUrl')).to include(:download_code)
+ expect(fetch_authorizations('mergedMergeRequestsUrl')).to include(:read_code)
end
end
describe 'closedMergeRequestsUrl' do
it 'has valid authorization' do
- expect(fetch_authorizations('closedMergeRequestsUrl')).to include(:download_code)
+ expect(fetch_authorizations('closedMergeRequestsUrl')).to include(:read_code)
end
end
describe 'openedIssuesUrl' do
it 'has valid authorization' do
- expect(fetch_authorizations('openedIssuesUrl')).to include(:download_code)
+ expect(fetch_authorizations('openedIssuesUrl')).to include(:read_code)
end
end
describe 'closedIssuesUrl' do
it 'has valid authorization' do
- expect(fetch_authorizations('closedIssuesUrl')).to include(:download_code)
+ expect(fetch_authorizations('closedIssuesUrl')).to include(:read_code)
end
end
diff --git a/spec/graphql/types/release_source_type_spec.rb b/spec/graphql/types/release_source_type_spec.rb
index 69a1ca30dbc..52f1e3a4ff5 100644
--- a/spec/graphql/types/release_source_type_spec.rb
+++ b/spec/graphql/types/release_source_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ReleaseSource'] do
- it { expect(described_class).to require_graphql_authorizations(:download_code) }
+ it { expect(described_class).to require_graphql_authorizations(:read_code) }
it 'has the expected fields' do
expected_fields = %w[
diff --git a/spec/graphql/types/repository_type_spec.rb b/spec/graphql/types/repository_type_spec.rb
index 5488d78b720..4ff2cbcad46 100644
--- a/spec/graphql/types/repository_type_spec.rb
+++ b/spec/graphql/types/repository_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Repository'] do
specify { expect(described_class.graphql_name).to eq('Repository') }
- specify { expect(described_class).to require_graphql_authorizations(:download_code) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_code) }
specify { expect(described_class).to have_graphql_field(:root_ref) }
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index e31298e489d..813b4b3faa6 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1331,6 +1331,8 @@ RSpec.describe Ci::Build do
end
context 'hide build token' do
+ let_it_be(:build) { FactoryBot.build(:ci_build, pipeline: pipeline) }
+
let(:data) { "new #{build.token} data" }
it { is_expected.to match(/^new x+ data$/) }
@@ -3811,6 +3813,26 @@ RSpec.describe Ci::Build do
build.enqueue
end
+
+ it 'assigns the token' do
+ expect { build.enqueue }.to change(build, :token).from(nil).to(an_instance_of(String))
+ end
+
+ context 'with ci_assign_job_token_on_scheduling disabled' do
+ before do
+ stub_feature_flags(ci_assign_job_token_on_scheduling: false)
+ end
+
+ it 'assigns the token on creation' do
+ expect(build.token).to be_present
+ end
+
+ it 'does not change the token when enqueuing' do
+ expect { build.enqueue }.not_to change(build, :token)
+
+ expect(build).to be_pending
+ end
+ end
end
describe 'state transition: pending: :running' do
diff --git a/spec/models/concerns/file_store_mounter_spec.rb b/spec/models/concerns/file_store_mounter_spec.rb
new file mode 100644
index 00000000000..459f3d35668
--- /dev/null
+++ b/spec/models/concerns/file_store_mounter_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FileStoreMounter, :aggregate_failures do
+ let(:uploader_class) do
+ Class.new do
+ def object_store
+ :object_store
+ end
+ end
+ end
+
+ let(:test_class) { Class.new { include(FileStoreMounter) } }
+
+ let(:uploader_instance) { uploader_class.new }
+
+ describe '.mount_file_store_uploader' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:mount_file_store_uploader) do
+ test_class.mount_file_store_uploader uploader_class, skip_store_file: skip_store_file, file_field: file_field
+ end
+
+ where(:skip_store_file, :file_field) do
+ true | :file
+ false | :file
+ false | :signed_file
+ true | :signed_file
+ end
+
+ with_them do
+ it 'defines instance methods and registers a callback' do
+ expect(test_class).to receive(:mount_uploader).with(file_field, uploader_class)
+ expect(test_class).to receive(:define_method).with("update_#{file_field}_store")
+ expect(test_class).to receive(:define_method).with("store_#{file_field}_now!")
+
+ if skip_store_file
+ expect(test_class).to receive(:skip_callback).with(:save, :after, "store_#{file_field}!".to_sym)
+ expect(test_class).not_to receive(:after_save)
+ else
+ expect(test_class).not_to receive(:skip_callback)
+ expect(test_class)
+ .to receive(:after_save)
+ .with("update_#{file_field}_store".to_sym, if: "saved_change_to_#{file_field}?".to_sym)
+ end
+
+ mount_file_store_uploader
+ end
+ end
+
+ context 'with an unknown file_field' do
+ let(:skip_store_file) { false }
+ let(:file_field) { 'unknown' }
+
+ it do
+ expect { mount_file_store_uploader }.to raise_error(ArgumentError, 'file_field not allowed: unknown')
+ end
+ end
+ end
+
+ context 'with an instance' do
+ let(:instance) { test_class.new }
+
+ before do
+ allow(test_class).to receive(:mount_uploader)
+ allow(test_class).to receive(:after_save)
+ test_class.mount_file_store_uploader uploader_class
+ end
+
+ describe '#update_file_store' do
+ subject(:update_file_store) { instance.update_file_store }
+
+ it 'calls update column' do
+ expect(instance).to receive(:file).and_return(uploader_instance)
+ expect(instance).to receive(:update_column).with('file_store', :object_store)
+
+ update_file_store
+ end
+ end
+
+ describe '#store_file_now!' do
+ subject(:store_file_now!) { instance.store_file_now! }
+
+ it 'calls the dynamic functions' do
+ expect(instance).to receive(:store_file!)
+ expect(instance).to receive(:update_file_store)
+
+ store_file_now!
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb
index a6a0e074589..b2ea7b22dea 100644
--- a/spec/models/concerns/has_user_type_spec.rb
+++ b/spec/models/concerns/has_user_type_spec.rb
@@ -88,5 +88,47 @@ RSpec.describe User do
end
end
end
+
+ describe '#redacted_name(viewing_user)' do
+ let_it_be(:viewing_user) { human }
+
+ subject { observed_user.redacted_name(viewing_user) }
+
+ context 'when user is not a project bot' do
+ let(:observed_user) { support_bot }
+
+ it { is_expected.to eq(support_bot.name) }
+ end
+
+ context 'when user is a project_bot' do
+ let(:observed_user) { project_bot }
+
+ context 'when groups are present and user can :read_group' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ group.add_developer(observed_user)
+ group.add_developer(viewing_user)
+ end
+
+ it { is_expected.to eq(observed_user.name) }
+ end
+
+ context 'when user can :read_project' do
+ let_it_be(:project) { create(:project) }
+
+ before do
+ project.add_developer(observed_user)
+ project.add_developer(viewing_user)
+ end
+
+ it { is_expected.to eq(observed_user.name) }
+ end
+
+ context 'when requester does not have permissions to read project_bot name' do
+ it { is_expected.to eq('****') }
+ end
+ end
+ end
end
end
diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb
index e8db83b7144..e53fdafe3b1 100644
--- a/spec/models/concerns/token_authenticatable_spec.rb
+++ b/spec/models/concerns/token_authenticatable_spec.rb
@@ -214,19 +214,15 @@ end
RSpec.describe Ci::Build, 'TokenAuthenticatable' do
let(:token_field) { :token }
- let(:build) { FactoryBot.build(:ci_build) }
+ let(:build) { FactoryBot.build(:ci_build, :created) }
it_behaves_like 'TokenAuthenticatable'
describe 'generating new token' do
context 'token is not generated yet' do
describe 'token field accessor' do
- it 'makes it possible to access token' do
- expect(build.token).to be_nil
-
- build.save!
-
- expect(build.token).to be_present
+ it 'does not generate a token when saving a build' do
+ expect { build.save! }.not_to change(build, :token).from(nil)
end
end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index 9554fc3bb1b..c665f738ead 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -314,7 +314,7 @@ RSpec.describe Packages::PackageFile, type: :model do
# to `1`.
expect(package_file)
.to receive(:update_column)
- .with(:file_store, ::Packages::PackageFileUploader::Store::LOCAL)
+ .with('file_store', ::Packages::PackageFileUploader::Store::LOCAL)
expect { subject }.to change { package_file.size }.from(nil).to(3513)
end
diff --git a/spec/requests/api/graphql/project/branch_rules_spec.rb b/spec/requests/api/graphql/project/branch_rules_spec.rb
index 65c519c46cf..ed866305445 100644
--- a/spec/requests/api/graphql/project/branch_rules_spec.rb
+++ b/spec/requests/api/graphql/project/branch_rules_spec.rb
@@ -7,10 +7,9 @@ RSpec.describe 'getting list of branch rules for a project' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:branch_name_a) { 'branch_name_a' }
- let_it_be(:branch_name_b) { 'wildcard-*' }
+ let_it_be(:branch_name_a) { TestEnv::BRANCH_SHA.each_key.first }
+ let_it_be(:branch_name_b) { 'diff-*' }
let_it_be(:branch_rules) { [branch_rule_a, branch_rule_b] }
-
let_it_be(:branch_rule_a) do
create(:protected_branch, project: project, name: branch_name_a)
end
@@ -21,7 +20,7 @@ RSpec.describe 'getting list of branch rules for a project' do
let(:branch_rules_data) { graphql_data_at('project', 'branchRules', 'edges') }
let(:variables) { { path: project.full_path } }
- let(:fields) { all_graphql_fields_for('BranchRule', max_depth: 2) }
+ let(:fields) { all_graphql_fields_for('BranchRule') }
let(:query) do
<<~GQL
query($path: ID!, $n: Int, $cursor: String) {
@@ -59,49 +58,124 @@ RSpec.describe 'getting list of branch rules for a project' do
context 'when the user does have read_protected_branch abilities' do
before do
project.add_maintainer(current_user)
- post_graphql(query, current_user: current_user, variables: variables)
end
- it_behaves_like 'a working graphql query'
+ describe 'queries' do
+ before do
+ # rubocop:disable RSpec/AnyInstanceOf
+ allow_any_instance_of(User).to receive(:update_tracked_fields!)
+ allow_any_instance_of(Users::ActivityService).to receive(:execute)
+ # rubocop:enable RSpec/AnyInstanceOf
+ allow_next_instance_of(Resolvers::ProjectResolver) do |resolver|
+ allow(resolver).to receive(:resolve)
+ .with(full_path: project.full_path)
+ .and_return(project)
+ end
+ allow(project.repository).to receive(:branch_names).and_call_original
+ allow(project.repository.gitaly_ref_client).to receive(:branch_names).and_call_original
+ end
+
+ it 'matching_branches_count avoids N+1 queries' do
+ query = <<~GQL
+ query($path: ID!) {
+ project(fullPath: $path) {
+ branchRules {
+ nodes {
+ matchingBranchesCount
+ }
+ }
+ }
+ }
+ GQL
+
+ control = ActiveRecord::QueryRecorder.new do
+ post_graphql(query, current_user: current_user, variables: variables)
+ end
+
+ # Verify the response includes the field
+ expect_n_matching_branches_count_fields(2)
+
+ create(:protected_branch, project: project)
+ create(:protected_branch, name: '*', project: project)
+
+ expect do
+ post_graphql(query, current_user: current_user, variables: variables)
+ end.not_to exceed_all_query_limit(control)
+
+ expect(project.repository).to have_received(:branch_names).at_least(2).times
+ expect(project.repository.gitaly_ref_client).to have_received(:branch_names).once
- it 'returns branch rules data' do
- expect(branch_rules_data.dig(0, 'node', 'name')).to be_present
- expect(branch_rules_data.dig(0, 'node', 'isDefault')).to be(true).or be(false)
- expect(branch_rules_data.dig(0, 'node', 'branchProtection')).to be_present
- expect(branch_rules_data.dig(0, 'node', 'createdAt')).to be_present
- expect(branch_rules_data.dig(0, 'node', 'updatedAt')).to be_present
-
- expect(branch_rules_data.dig(1, 'node', 'name')).to be_present
- expect(branch_rules_data.dig(1, 'node', 'isDefault')).to be(true).or be(false)
- expect(branch_rules_data.dig(1, 'node', 'branchProtection')).to be_present
- expect(branch_rules_data.dig(1, 'node', 'createdAt')).to be_present
- expect(branch_rules_data.dig(1, 'node', 'updatedAt')).to be_present
+ expect_n_matching_branches_count_fields(4)
+ end
+
+ def expect_n_matching_branches_count_fields(count)
+ branch_rule_nodes = graphql_data_at('project', 'branchRules', 'nodes')
+ expect(branch_rule_nodes.count).to eq(count)
+ branch_rule_nodes.each do |node|
+ expect(node['matchingBranchesCount']).to be_present
+ end
+ end
end
- context 'when limiting the number of results' do
- let(:branch_rule_limit) { 1 }
- let(:variables) { { path: project.full_path, n: branch_rule_limit } }
- let(:next_variables) do
- { path: project.full_path, n: branch_rule_limit, cursor: last_cursor }
+ describe 'response' do
+ before do
+ post_graphql(query, current_user: current_user, variables: variables)
end
it_behaves_like 'a working graphql query'
- it 'returns pagination information' do
- expect(branch_rules_data.size).to eq(branch_rule_limit)
- expect(has_next_page).to be_truthy
- expect(has_prev_page).to be_falsey
- post_graphql(query, current_user: current_user, variables: next_variables)
- expect(branch_rules_data.size).to eq(branch_rule_limit)
- expect(has_next_page).to be_falsey
- expect(has_prev_page).to be_truthy
+ it 'includes all fields', :aggregate_failures do
+ # Responses will be sorted alphabetically. Branch names for this spec
+ # come from an external constant so we check which is first
+ br_a_idx = branch_name_a < branch_name_b ? 0 : 1
+ br_b_idx = 1 - br_a_idx
+
+ branch_rule_a_data = branch_rules_data.dig(br_a_idx, 'node')
+ branch_rule_b_data = branch_rules_data.dig(br_b_idx, 'node')
+
+ expect(branch_rule_a_data['name']).to eq(branch_name_a)
+ expect(branch_rule_a_data['isDefault']).to be(true).or be(false)
+ expect(branch_rule_a_data['branchProtection']).to be_present
+ expect(branch_rule_a_data['matchingBranchesCount']).to eq(1)
+ expect(branch_rule_a_data['createdAt']).to be_present
+ expect(branch_rule_a_data['updatedAt']).to be_present
+
+ wildcard_count = TestEnv::BRANCH_SHA.keys.count do |branch_name|
+ branch_name.starts_with?('diff-')
+ end
+ expect(branch_rule_b_data['name']).to eq(branch_name_b)
+ expect(branch_rule_b_data['isDefault']).to be(true).or be(false)
+ expect(branch_rule_b_data['branchProtection']).to be_present
+ expect(branch_rule_b_data['matchingBranchesCount']).to eq(wildcard_count)
+ expect(branch_rule_b_data['createdAt']).to be_present
+ expect(branch_rule_b_data['updatedAt']).to be_present
end
- context 'when no limit is provided' do
- let(:branch_rule_limit) { nil }
+ context 'when limiting the number of results' do
+ let(:branch_rule_limit) { 1 }
+ let(:variables) { { path: project.full_path, n: branch_rule_limit } }
+ let(:next_variables) do
+ { path: project.full_path, n: branch_rule_limit, cursor: last_cursor }
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns pagination information' do
+ expect(branch_rules_data.size).to eq(branch_rule_limit)
+ expect(has_next_page).to be_truthy
+ expect(has_prev_page).to be_falsey
+ post_graphql(query, current_user: current_user, variables: next_variables)
+ expect(branch_rules_data.size).to eq(branch_rule_limit)
+ expect(has_next_page).to be_falsey
+ expect(has_prev_page).to be_truthy
+ end
+
+ context 'when no limit is provided' do
+ let(:branch_rule_limit) { nil }
- it 'returns all branch_rules' do
- expect(branch_rules_data.size).to eq(branch_rules.size)
+ it 'returns all branch_rules' do
+ expect(branch_rules_data.size).to eq(branch_rules.size)
+ end
end
end
end
diff --git a/spec/requests/groups/settings/access_tokens_controller_spec.rb b/spec/requests/groups/settings/access_tokens_controller_spec.rb
index 0d3f0dbe6e0..6b150e0acb6 100644
--- a/spec/requests/groups/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/groups/settings/access_tokens_controller_spec.rb
@@ -5,11 +5,11 @@ require 'spec_helper'
RSpec.describe Groups::Settings::AccessTokensController do
let_it_be(:user) { create(:user) }
let_it_be(:resource) { create(:group) }
- let_it_be(:bot_user) { create(:user, :project_bot) }
+ let_it_be(:access_token_user) { create(:user, :project_bot) }
before_all do
resource.add_owner(user)
- resource.add_maintainer(bot_user)
+ resource.add_maintainer(access_token_user)
end
before do
@@ -44,6 +44,7 @@ RSpec.describe Groups::Settings::AccessTokensController do
it_behaves_like 'feature unavailable'
it_behaves_like 'GET resource access tokens available'
+ it_behaves_like 'GET access tokens are paginated and ordered'
end
describe 'POST /:namespace/-/settings/access_tokens' do
@@ -87,7 +88,7 @@ RSpec.describe Groups::Settings::AccessTokensController do
end
describe 'PUT /:namespace/-/settings/access_tokens/:id', :sidekiq_inline do
- let(:resource_access_token) { create(:personal_access_token, user: bot_user) }
+ let(:resource_access_token) { create(:personal_access_token, user: access_token_user) }
subject do
put revoke_group_settings_access_token_path(resource, resource_access_token)
@@ -99,17 +100,17 @@ RSpec.describe Groups::Settings::AccessTokensController do
end
describe '#index' do
- let_it_be(:resource_access_tokens) { create_list(:personal_access_token, 3, user: bot_user) }
+ let_it_be(:resource_access_tokens) { create_list(:personal_access_token, 3, user: access_token_user) }
before do
get group_settings_access_tokens_path(resource)
end
it 'includes details of the active group access tokens' do
- active_resource_access_tokens =
+ active_access_tokens =
::GroupAccessTokenSerializer.new.represent(resource_access_tokens.reverse, group: resource)
- expect(assigns(:active_resource_access_tokens).to_json).to eq(active_resource_access_tokens.to_json)
+ expect(assigns(:active_access_tokens).to_json).to eq(active_access_tokens.to_json)
end
end
end
diff --git a/spec/requests/projects/settings/access_tokens_controller_spec.rb b/spec/requests/projects/settings/access_tokens_controller_spec.rb
index 5fe97ef8ebc..17389cdcce7 100644
--- a/spec/requests/projects/settings/access_tokens_controller_spec.rb
+++ b/spec/requests/projects/settings/access_tokens_controller_spec.rb
@@ -6,11 +6,11 @@ RSpec.describe Projects::Settings::AccessTokensController do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:resource) { create(:project, group: group) }
- let_it_be(:bot_user) { create(:user, :project_bot) }
+ let_it_be(:access_token_user) { create(:user, :project_bot) }
before_all do
resource.add_maintainer(user)
- resource.add_maintainer(bot_user)
+ resource.add_maintainer(access_token_user)
end
before do
@@ -45,6 +45,7 @@ RSpec.describe Projects::Settings::AccessTokensController do
it_behaves_like 'feature unavailable'
it_behaves_like 'GET resource access tokens available'
+ it_behaves_like 'GET access tokens are paginated and ordered'
end
describe 'POST /:namespace/:project/-/settings/access_tokens' do
@@ -88,7 +89,7 @@ RSpec.describe Projects::Settings::AccessTokensController do
end
describe 'PUT /:namespace/:project/-/settings/access_tokens/:id', :sidekiq_inline do
- let(:resource_access_token) { create(:personal_access_token, user: bot_user) }
+ let(:resource_access_token) { create(:personal_access_token, user: access_token_user) }
subject do
put revoke_project_settings_access_token_path(resource, resource_access_token)
@@ -100,17 +101,17 @@ RSpec.describe Projects::Settings::AccessTokensController do
end
describe '#index' do
- let_it_be(:resource_access_tokens) { create_list(:personal_access_token, 3, user: bot_user) }
+ let_it_be(:resource_access_tokens) { create_list(:personal_access_token, 3, user: access_token_user) }
before do
get project_settings_access_tokens_path(resource)
end
it 'includes details of the active project access tokens' do
- active_resource_access_tokens =
+ active_access_tokens =
::ProjectAccessTokenSerializer.new.represent(resource_access_tokens.reverse, project: resource)
- expect(assigns(:active_resource_access_tokens).to_json).to eq(active_resource_access_tokens.to_json)
+ expect(assigns(:active_access_tokens).to_json).to eq(active_access_tokens.to_json)
end
end
end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 0757e9b8bbb..18ad946b289 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Users::DestroyService do
let(:service) { described_class.new(admin) }
let(:gitlab_shell) { Gitlab::Shell.new }
- describe "Deletes a user and all their personal projects", :enable_admin_mode do
+ describe "Initiates user deletion and deletes all their personal projects", :enable_admin_mode do
context 'no options are given' do
it 'creates GhostUserMigration record to handle migration in a worker' do
expect { service.execute(user) }
@@ -163,6 +163,44 @@ RSpec.describe Users::DestroyService do
service.execute(user)
end
end
+
+ describe 'prometheus metrics', :prometheus do
+ context 'scheduled records' do
+ context 'with a single record' do
+ it 'updates the scheduled records gauge' do
+ service.execute(user)
+
+ gauge = Gitlab::Metrics.registry.get(:gitlab_ghost_user_migration_scheduled_records_total)
+ expect(gauge.get).to eq(1)
+ end
+ end
+
+ context 'with approximate count due to large number of records' do
+ it 'updates the scheduled records gauge' do
+ allow(Users::GhostUserMigration)
+ .to(receive_message_chain(:limit, :count).and_return(1001))
+ allow(Users::GhostUserMigration).to(receive(:minimum)).and_return(42)
+ allow(Users::GhostUserMigration).to(receive(:maximum)).and_return(9042)
+
+ service.execute(user)
+
+ gauge = Gitlab::Metrics.registry.get(:gitlab_ghost_user_migration_scheduled_records_total)
+ expect(gauge.get).to eq(9000)
+ end
+ end
+ end
+
+ context 'lag' do
+ it 'update the lag gauge', :freeze_time do
+ create(:ghost_user_migration, created_at: 10.minutes.ago)
+
+ service.execute(user)
+
+ gauge = Gitlab::Metrics.registry.get(:gitlab_ghost_user_migration_lag_seconds)
+ expect(gauge.get).to eq(600)
+ end
+ end
+ end
end
describe "Deletion permission checks" do
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index 4940c0d04cc..32a7b32ac72 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -99,14 +99,14 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes
end
RSpec.shared_examples 'active resource access tokens' do
- def active_resource_access_tokens
+ def active_access_tokens
find("[data-testid='active-tokens']")
end
it 'shows active access tokens' do
visit resource_settings_access_tokens_path
- expect(active_resource_access_tokens).to have_text(resource_access_token.name)
+ expect(active_access_tokens).to have_text(resource_access_token.name)
end
context 'when User#time_display_relative is false' do
@@ -117,13 +117,13 @@ RSpec.shared_examples 'active resource access tokens' do
it 'shows absolute times for expires_at' do
visit resource_settings_access_tokens_path
- expect(active_resource_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d'))
+ expect(active_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d'))
end
end
end
RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_text|
- def active_resource_access_tokens
+ def active_access_tokens
find("[data-testid='active-tokens']")
end
@@ -131,14 +131,14 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex
visit resource_settings_access_tokens_path
accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' }
- expect(active_resource_access_tokens).to have_text(no_active_tokens_text)
+ expect(active_access_tokens).to have_text(no_active_tokens_text)
end
it 'removes expired tokens from active section' do
resource_access_token.update!(expires_at: 5.days.ago)
visit resource_settings_access_tokens_path
- expect(active_resource_access_tokens).to have_text(no_active_tokens_text)
+ expect(active_access_tokens).to have_text(no_active_tokens_text)
end
context 'when resource access token creation is not allowed' do
@@ -150,7 +150,7 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex
visit resource_settings_access_tokens_path
accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' }
- expect(active_resource_access_tokens).to have_text(no_active_tokens_text)
+ expect(active_access_tokens).to have_text(no_active_tokens_text)
end
end
end
diff --git a/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb b/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
index 23026167b19..5be0f6349ea 100644
--- a/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
+++ b/spec/support/shared_examples/models/packages/debian/component_file_shared_example.rb
@@ -199,7 +199,7 @@ RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
expect(component_file)
.to receive(:update_column)
- .with(:file_store, ::Packages::PackageFileUploader::Store::LOCAL)
+ .with('file_store', ::Packages::PackageFileUploader::Store::LOCAL)
.and_call_original
expect { subject }.to change { component_file.size }.from(nil).to(74)
diff --git a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
index 93519d4e57e..2170025824f 100644
--- a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
+++ b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
@@ -1,12 +1,12 @@
# frozen_string_literal: true
RSpec.shared_examples 'GET resource access tokens available' do
- let_it_be(:active_resource_access_token) { create(:personal_access_token, user: bot_user) }
+ let_it_be(:active_resource_access_token) { create(:personal_access_token, user: access_token_user) }
- it 'retrieves active resource access tokens' do
+ it 'retrieves active access tokens' do
get_access_tokens
- token_entities = assigns(:active_resource_access_tokens)
+ token_entities = assigns(:active_access_tokens)
expect(token_entities.length).to eq(1)
expect(token_entities[0][:name]).to eq(active_resource_access_token.name)
end
@@ -22,16 +22,22 @@ RSpec.shared_examples 'GET resource access tokens available' do
expect(json_response.count).to eq(1)
end
+end
+
+RSpec.shared_examples 'GET access tokens are paginated and ordered' do
+ before do
+ create(:personal_access_token, user: access_token_user)
+ end
- context "when access_tokens are paginated" do
+ context "when multiple access tokens are returned" do
before do
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
- create(:personal_access_token, user: bot_user)
+ create(:personal_access_token, user: access_token_user)
end
it "returns paginated response", :aggregate_failures do
get_access_tokens_with_page
- expect(assigns(:active_resource_access_tokens).count).to eq(1)
+ expect(assigns(:active_access_tokens).count).to eq(1)
expect_header('X-Per-Page', '1')
expect_header('X-Page', '1')
@@ -43,41 +49,42 @@ RSpec.shared_examples 'GET resource access tokens available' do
context "when access_token_pagination feature flag is disabled" do
before do
stub_feature_flags(access_token_pagination: false)
- create(:personal_access_token, user: bot_user)
+ create(:personal_access_token, user: access_token_user)
end
it "returns all tokens in system" do
get_access_tokens_with_page
- expect(assigns(:active_resource_access_tokens).count).to eq(2)
+ expect(assigns(:active_access_tokens).count).to eq(2)
end
end
- context "as tokens returned are ordered" do
+ context "when tokens returned are ordered" do
let(:expires_1_day_from_now) { 1.day.from_now.to_date }
let(:expires_2_day_from_now) { 2.days.from_now.to_date }
before do
- create(:personal_access_token, user: bot_user, name: "Token1", expires_at: expires_1_day_from_now)
- create(:personal_access_token, user: bot_user, name: "Token2", expires_at: expires_2_day_from_now)
+ create(:personal_access_token, user: access_token_user, name: "Token1", expires_at: expires_1_day_from_now)
+ create(:personal_access_token, user: access_token_user, name: "Token2", expires_at: expires_2_day_from_now)
end
it "orders token list ascending on expires_at" do
get_access_tokens
- first_token = assigns(:active_resource_access_tokens).first.as_json
+ first_token = assigns(:active_access_tokens).first.as_json
expect(first_token['name']).to eq("Token1")
expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
end
it "orders tokens on id in case token has same expires_at" do
- create(:personal_access_token, user: bot_user, name: "Token3", expires_at: expires_1_day_from_now)
+ create(:personal_access_token, user: access_token_user, name: "Token3", expires_at: expires_1_day_from_now)
+
get_access_tokens
- first_token = assigns(:active_resource_access_tokens).first.as_json
+ first_token = assigns(:active_access_tokens).first.as_json
expect(first_token['name']).to eq("Token3")
expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
- second_token = assigns(:active_resource_access_tokens).second.as_json
+ second_token = assigns(:active_access_tokens).second.as_json
expect(second_token['name']).to eq("Token1")
expect(second_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
end
@@ -153,7 +160,7 @@ end
RSpec.shared_examples 'PUT resource access tokens available' do
it 'calls delete user worker' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, bot_user.id, skip_authorization: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, access_token_user.id, skip_authorization: true)
subject
end
@@ -161,7 +168,7 @@ RSpec.shared_examples 'PUT resource access tokens available' do
it 'removes membership of bot user' do
subject
- expect(resource.reload.bots).not_to include(bot_user)
+ expect(resource.reload.bots).not_to include(access_token_user)
end
it 'creates GhostUserMigration records to handle migration in a worker' do
diff --git a/test.html b/test.html
deleted file mode 100644
index 9c508e519c5..00000000000
--- a/test.html
+++ /dev/null
@@ -1,439 +0,0 @@
-<!DOCTYPE html>
-<html class="" lang="en">
-<head prefix="og: http://ogp.me/ns#">
-<meta charset="utf-8">
-<title>term · Search · GitLab</title>
-<link rel="preload" href="/assets/application_utilities-08432cf9120e4223aaf60df81aa67b3a688203198905c5ee86fc3c7e2133dd8b.css" as="style" type="text/css" nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-<link rel="preload" href="/assets/application-83d43ac2aff2d407da96a5fd6a410aa784c1ada12f2e8e02b328764d02324432.css" as="style" type="text/css" nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-<link rel="preload" href="/assets/highlight/themes/white-557ba28a0d83a177dd5f4cdaa59e208f666e026683c63c59f494ece39cb34f98.css" as="style" type="text/css" nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-<link crossorigin="" href="https://localhost" rel="preconnect">
-
-<meta content="IE=edge" http-equiv="X-UA-Compatible">
-
-
-<link rel="shortcut icon" type="image/png" href="/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8cd39788c7c80d5b2b82aa5143ef.png" id="favicon" data-original-href="/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8cd39788c7c80d5b2b82aa5143ef.png" />
-<style>
-@keyframes blinking-dot{0%{opacity:1}25%{opacity:0.4}75%{opacity:0.4}100%{opacity:1}}@keyframes blinking-scroll-button{0%{opacity:0.2}50%{opacity:1}100%{opacity:0.2}}@keyframes gl-spinner-rotate{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}body.ui-indigo{--gl-theme-accent: #6666c4}body.ui-indigo .navbar-gitlab{background-color:#292961}body.ui-indigo .navbar-gitlab .navbar-collapse{color:#d1d1f0}body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler{border-left:1px solid #6868b9;color:#d1d1f0}body.ui-indigo .navbar-gitlab .navbar-sub-nav>li>a:hover,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li>a:focus,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li>button:hover,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li>button:focus,body.ui-indigo .navbar-gitlab .navbar-nav>li>a:hover,body.ui-indigo .navbar-gitlab .navbar-nav>li>a:focus,body.ui-indigo .navbar-gitlab .navbar-nav>li>button:hover,body.ui-indigo .navbar-gitlab .navbar-nav>li>button:focus{background-color:rgba(209,209,240,0.2)}body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.active>a,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.active>button,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.dropdown.show>a,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.dropdown.show>button,body.ui-indigo .navbar-gitlab .navbar-nav>li.active>a,body.ui-indigo .navbar-gitlab .navbar-nav>li.active>button,body.ui-indigo .navbar-gitlab .navbar-nav>li.dropdown.show>a,body.ui-indigo .navbar-gitlab .navbar-nav>li.dropdown.show>button{color:#292961;background-color:#fff}body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.line-separator,body.ui-indigo .navbar-gitlab .navbar-nav>li.line-separator{border-left:1px solid rgba(209,209,240,0.2)}body.ui-indigo .navbar-gitlab .navbar-sub-nav{color:#d1d1f0}body.ui-indigo .navbar-gitlab .nav>li{color:#d1d1f0}body.ui-indigo .navbar-gitlab .nav>li.header-search-new{color:#303030}body.ui-indigo .navbar-gitlab .nav>li>a .notification-dot{border:2px solid #292961}body.ui-indigo .navbar-gitlab .nav>li>a.header-help-dropdown-toggle .notification-dot{background-color:#d1d1f0}body.ui-indigo .navbar-gitlab .nav>li>a.header-user-dropdown-toggle .header-user-avatar{border-color:#d1d1f0}@media (min-width: 576px){body.ui-indigo .navbar-gitlab .nav>li>a:hover,body.ui-indigo .navbar-gitlab .nav>li>a:focus{background-color:rgba(209,209,240,0.2)}}body.ui-indigo .navbar-gitlab .nav>li>a:hover svg,body.ui-indigo .navbar-gitlab .nav>li>a:focus svg{fill:currentColor}body.ui-indigo .navbar-gitlab .nav>li>a:hover .notification-dot,body.ui-indigo .navbar-gitlab .nav>li>a:focus .notification-dot{will-change:border-color, background-color;border-color:#4a4a82}body.ui-indigo .navbar-gitlab .nav>li>a.header-help-dropdown-toggle:hover .notification-dot,body.ui-indigo .navbar-gitlab .nav>li>a.header-help-dropdown-toggle:focus .notification-dot{background-color:#fff}body.ui-indigo .navbar-gitlab .nav>li.active>a,body.ui-indigo .navbar-gitlab .nav>li.dropdown.show>a{color:#292961;background-color:#fff}body.ui-indigo .navbar-gitlab .nav>li.active>a:hover svg,body.ui-indigo .navbar-gitlab .nav>li.dropdown.show>a:hover svg{fill:#292961}body.ui-indigo .navbar-gitlab .nav>li.active>a .notification-dot,body.ui-indigo .navbar-gitlab .nav>li.dropdown.show>a .notification-dot{border-color:#fff}body.ui-indigo .navbar-gitlab .nav>li.active>a.header-help-dropdown-toggle .notification-dot,body.ui-indigo .navbar-gitlab .nav>li.dropdown.show>a.header-help-dropdown-toggle .notification-dot{background-color:#292961}body.ui-indigo .navbar-gitlab .nav>li .impersonated-user svg,body.ui-indigo .navbar-gitlab .nav>li .impersonated-user:hover svg{fill:#292961}body.ui-indigo .navbar .title>a:hover,body.ui-indigo .navbar .title>a:focus{background-color:rgba(209,209,240,0.2)}body.ui-indigo .header-search{background-color:rgba(209,209,240,0.2) !important;border-radius:4px}body.ui-indigo .header-search:hover{background-color:rgba(209,209,240,0.3) !important}body.ui-indigo .header-search svg.gl-search-box-by-type-search-icon{color:rgba(209,209,240,0.8)}body.ui-indigo .header-search input{background-color:transparent;color:rgba(209,209,240,0.8);box-shadow:inset 0 0 0 1px rgba(209,209,240,0.4)}body.ui-indigo .header-search input::placeholder{color:rgba(209,209,240,0.8)}body.ui-indigo .header-search input:focus::placeholder,body.ui-indigo .header-search input:active::placeholder{color:#868686}body.ui-indigo .header-search .keyboard-shortcut-helper{color:#d1d1f0;background-color:rgba(209,209,240,0.2)}body.ui-indigo .search form{background-color:rgba(209,209,240,0.2)}body.ui-indigo .search form:hover{background-color:rgba(209,209,240,0.3)}body.ui-indigo .search .search-input::placeholder{color:rgba(209,209,240,0.8)}body.ui-indigo .search .search-input-wrap .search-icon,body.ui-indigo .search .search-input-wrap .clear-icon{fill:rgba(209,209,240,0.8)}body.ui-indigo .search.search-active form{background-color:#fff}body.ui-indigo .search.search-active .search-input-wrap .search-icon{fill:rgba(209,209,240,0.8)}body.ui-indigo .nav-sidebar li.active>a{color:#303030}body.ui-indigo .nav-sidebar .fly-out-top-item a,body.ui-indigo .nav-sidebar .fly-out-top-item a:hover,body.ui-indigo .nav-sidebar .fly-out-top-item.active a,body.ui-indigo .nav-sidebar .fly-out-top-item .fly-out-top-item-container{background-color:var(--gray-100, #f0f0f0);color:var(--gray-900, #303030)}body.ui-indigo .branch-header-title{color:#4b4ba3}body.ui-indigo .ide-sidebar-link.active{color:#4b4ba3}body.ui-indigo .ide-sidebar-link.active.is-right{box-shadow:inset -3px 0 #4b4ba3}
-
-*,*::before,*::after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15}aside,header{display:block}body{margin:0;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#303030;text-align:left;background-color:#fff}ul{margin-top:0;margin-bottom:1rem}ul ul{margin-bottom:0}strong{font-weight:bolder}a{color:#1f75cb;text-decoration:none;background-color:transparent}a:not([href]):not([class]){color:inherit;text-decoration:none}kbd{font-family:"Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;font-size:1em}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}button{border-radius:0}input,button{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button{text-transform:none}[role="button"]{cursor:pointer}button:not(:disabled),[type="button"]:not(:disabled){cursor:pointer}button::-moz-focus-inner,[type="button"]::-moz-focus-inner{padding:0;border-style:none}[type="search"]{outline-offset:-2px}.list-unstyled{padding-left:0;list-style:none}kbd{padding:0.2rem 0.4rem;font-size:90%;color:#fff;background-color:#303030;border-radius:0.2rem}kbd kbd{padding:0;font-size:100%;font-weight:600}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.form-control{display:block;width:100%;height:34px;padding:0.375rem 0.75rem;font-size:0.875rem;font-weight:400;line-height:1.5;color:#303030;background-color:#fff;background-clip:padding-box;border:1px solid #868686;border-radius:0.25rem}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #303030}.form-control::placeholder{color:#5e5e5e;opacity:1}.form-control:disabled{background-color:#fafafa;opacity:1}.form-inline{display:flex;flex-flow:row wrap;align-items:center}@media (min-width: 576px){.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}}.btn{display:inline-block;font-weight:400;color:#303030;text-align:center;vertical-align:middle;-webkit-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:0.375rem 0.75rem;font-size:1rem;line-height:20px;border-radius:0.25rem}.btn:disabled{opacity:0.65}.btn:not(:disabled):not(.disabled){cursor:pointer}.collapse:not(.show){display:none}.dropdown{position:relative}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:0.5rem 0;margin:0.125rem 0 0;font-size:1rem;color:#303030;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,0.15);border-radius:0.25rem}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:0.25rem 0.5rem}.navbar .container-fluid{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:0.25rem 0.75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:0.25rem}@media (max-width: 575.98px){.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width: 576px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm>.container-fluid{flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:flex !important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}.badge{display:inline-block;padding:0.25em 0.4em;font-size:75%;font-weight:600;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:0.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:0.6em;padding-left:0.6em;border-radius:10rem}.badge-success{color:#fff;background-color:#108548}.badge-info{color:#fff;background-color:#1f75cb}.badge-warning{color:#fff;background-color:#ab6100}.rounded-circle{border-radius:50% !important}.d-none{display:none !important}.d-block{display:block !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-inline-block{display:inline-block !important}}@media (min-width: 768px){.d-md-block{display:block !important}}@media (min-width: 992px){.d-lg-none{display:none !important}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0}.gl-avatar{border-width:1px;border-style:solid;border-color:rgba(0,0,0,0.08);overflow:hidden;flex-shrink:0}.gl-avatar-s24{width:1.5rem;height:1.5rem;font-size:0.75rem;line-height:1rem;border-radius:0.25rem}.gl-avatar-circle{border-radius:50%}.gl-badge{display:inline-flex;align-items:center;font-size:0.75rem;font-weight:400;line-height:1rem;padding-top:0.25rem;padding-bottom:0.25rem;padding-left:0.5rem;padding-right:0.5rem}.gl-badge.sm{padding-top:0;padding-bottom:0}.gl-badge.badge-info{background-color:#cbe2f9;color:#0b5cad}a.gl-badge.badge-info.active,a.gl-badge.badge-info:active{color:#033464;background-color:#9dc7f1}a.gl-badge.badge-info:active{box-shadow:0 0 0 1px #fff, 0 0 0 3px #428fdc;outline:none}.gl-badge.badge-success{background-color:#c3e6cd;color:#24663b}a.gl-badge.badge-success.active,a.gl-badge.badge-success:active{color:#0a4020;background-color:#91d4a8}a.gl-badge.badge-success:active{box-shadow:0 0 0 1px #fff, 0 0 0 3px #428fdc;outline:none}.gl-badge.badge-warning{background-color:#f5d9a8;color:#8f4700}a.gl-badge.badge-warning.active,a.gl-badge.badge-warning:active{color:#5c2900;background-color:#e9be74}a.gl-badge.badge-warning:active{box-shadow:0 0 0 1px #fff, 0 0 0 3px #428fdc;outline:none}.gl-button .gl-badge{top:0}.gl-form-input,.gl-form-input.form-control{background-color:#fff;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:0.875rem;line-height:1rem;padding-top:0.5rem;padding-bottom:0.5rem;padding-left:0.75rem;padding-right:0.75rem;height:auto;color:#303030;box-shadow:inset 0 0 0 1px #868686;border-style:none;-webkit-appearance:none;appearance:none;-moz-appearance:none}.gl-form-input:disabled,.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,.gl-form-input.form-control:disabled,.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only{background-color:#f5f5f5;box-shadow:inset 0 0 0 1px #dbdbdb}.gl-form-input:disabled,.gl-form-input.form-control:disabled{cursor:not-allowed;color:#666}.gl-form-input::placeholder,.gl-form-input.form-control::placeholder{color:#868686}.gl-icon{fill:currentColor}.gl-icon.s12{width:12px;height:12px}.gl-icon.s16{width:16px;height:16px}.gl-icon.s32{width:32px;height:32px}.gl-link{font-size:0.875rem;color:#1f75cb}.gl-link:active{color:#0b5cad}.gl-link:active{text-decoration:underline;outline:2px solid #428fdc;outline-offset:2px}.gl-button{display:inline-flex}.gl-button:not(.btn-link):active{text-decoration:none}.gl-button.gl-button{border-width:0;padding-top:0.5rem;padding-bottom:0.5rem;padding-left:0.75rem;padding-right:0.75rem;background-color:transparent;line-height:1rem;color:#303030;fill:currentColor;box-shadow:inset 0 0 0 1px #bfbfbf;justify-content:center;align-items:center;font-size:0.875rem;border-radius:0.25rem}.gl-button.gl-button.btn-default{background-color:#fff}.gl-button.gl-button.btn-default:active,.gl-button.gl-button.btn-default.active{box-shadow:inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc;outline:none;background-color:#dbdbdb}.gl-button.gl-button.btn-default:active .gl-icon,.gl-button.gl-button.btn-default.active .gl-icon{color:#303030}.gl-button.gl-button.btn-default .gl-icon{color:#666}.gl-search-box-by-type-search-icon{margin:0.5rem;color:#666;width:1rem;position:absolute}.gl-search-box-by-type{display:flex;position:relative}.gl-search-box-by-type-input,.gl-search-box-by-type-input.gl-form-input{height:2rem;padding-right:2rem;padding-left:1.75rem}body{font-size:0.875rem}button,html [type="button"],[role="button"]{cursor:pointer}strong{font-weight:bold}svg{vertical-align:baseline}.form-control,.search form{font-size:0.875rem}.hidden{display:none !important;visibility:hidden !important}.hide{display:none}.badge:not(.gl-badge){padding:4px 5px;font-size:12px;font-style:normal;font-weight:400;display:inline-block}.divider{height:0;margin:4px 0;overflow:hidden;border-top:1px solid #dbdbdb}.toggle-sidebar-button .collapse-text,.toggle-sidebar-button .icon-chevron-double-lg-left{color:#666}html{overflow-y:scroll}.btn{border-radius:4px;font-size:0.875rem;font-weight:400;padding:6px 10px;background-color:#fff;border-color:#dbdbdb;color:#303030;color:#303030;white-space:nowrap}.btn:active{background-color:#f0f0f0;box-shadow:none}.btn:active,.btn.active{background-color:#eaeaea;border-color:#e3e3e3;color:#303030}.btn svg{height:15px;width:15px}.btn svg:not(:last-child){margin-right:5px}.badge.badge-pill:not(.gl-badge){font-weight:400;background-color:rgba(0,0,0,0.07);color:#525252;vertical-align:baseline}.gl-font-sm{font-size:12px}.dropdown{position:relative}.dropdown-menu-toggle:active{box-shadow:0 0 0 1px #fff, 0 0 0 3px #428fdc;outline:none}.search-input-container .dropdown-menu{margin-top:11px}.dropdown-menu-toggle{padding:6px 8px 6px 10px;background-color:#fff;color:#303030;font-size:14px;text-align:left;border:1px solid #dbdbdb;border-radius:0.25rem;white-space:nowrap}.dropdown-menu-toggle.no-outline{outline:0}.dropdown-menu-toggle.dropdown-menu-toggle{justify-content:flex-start;overflow:hidden;padding-right:25px;position:relative;text-overflow:ellipsis;width:160px}.dropdown-menu{display:none;position:absolute;width:auto;top:100%;z-index:300;min-width:240px;max-width:500px;margin-top:4px;margin-bottom:24px;font-size:0.875rem;font-weight:400;padding:8px 0;background-color:#fff;border:1px solid #dbdbdb;border-radius:0.25rem;box-shadow:0 2px 4px rgba(0,0,0,0.1)}.dropdown-menu ul{margin:0;padding:0}.dropdown-menu li{display:block;text-align:left;list-style:none}.dropdown-menu li>a,.dropdown-menu li button{background:transparent;border:0;border-radius:0;box-shadow:none;display:block;font-weight:400;position:relative;padding:8px 12px;color:#303030;line-height:16px;white-space:normal;overflow:hidden;text-align:left;width:100%}.dropdown-menu li>a:active,.dropdown-menu li button:active{background-color:#eee;color:#303030;outline:0;text-decoration:none}.dropdown-menu li>a:active,.dropdown-menu li button:active{box-shadow:inset 0 0 0 2px #428fdc, inset 0 0 0 3px #fff, inset 0 0 0 1px #fff;outline:none}.dropdown-menu .divider{height:1px;margin:0.25rem 0;padding:0;background-color:#dbdbdb}.dropdown-menu .badge.badge-pill+span:not(.badge):not(.badge-pill){margin-right:40px}@media (max-width: 575.98px){.navbar-gitlab li.dropdown{position:static}.navbar-gitlab li.dropdown.user-counter{margin-left:8px !important}.navbar-gitlab li.dropdown.user-counter>a{padding:0 4px !important}header.navbar-gitlab .dropdown .dropdown-menu{width:100%;min-width:100%}}@media (max-width: 767.98px){.dropdown-menu-toggle.dropdown-menu-toggle{width:100%}}input{border-radius:0.25rem;color:#303030;background-color:#fff}.form-control{border-radius:4px;padding:6px 10px}.form-control::placeholder{color:#868686}kbd{display:inline-block;padding:3px 5px;font-size:0.6875rem;line-height:10px;color:var(--gray-700, #525252);vertical-align:middle;background-color:var(--gray-10, #f5f5f5);border-width:1px;border-style:solid;border-color:var(--gray-100, #dbdbdb) var(--gray-100, #dbdbdb) var(--gray-200, #bfbfbf);border-image:none;border-radius:3px;box-shadow:0 -1px 0 var(--gray-200, #bfbfbf) inset}.navbar-gitlab{padding:0 16px;z-index:1000;margin-bottom:0;min-height:var(--header-height, 48px);border:0;position:fixed;top:0;left:0;right:0;border-radius:0}.navbar-gitlab .close-icon{display:none}.navbar-gitlab .header-content{width:100%;display:flex;justify-content:space-between;position:relative;min-height:var(--header-height, 48px);padding-left:0}.navbar-gitlab .header-content .title{padding-right:0;color:currentColor;display:flex;position:relative;margin:0;font-size:18px;vertical-align:top;white-space:nowrap}.navbar-gitlab .header-content .title img{height:24px}.navbar-gitlab .header-content .title a:not(.canary-badge){display:flex;align-items:center;padding:2px 8px;margin:4px 2px 4px -8px;border-radius:4px}.navbar-gitlab .header-content .title a:not(.canary-badge):active{box-shadow:0 0 0 1px rgba(0,0,0,0.6),0 0 0 3px #63a6e9;outline:none}.navbar-gitlab .header-content .navbar-collapse>ul.nav>li:not(.d-none){margin:0 2px}.navbar-gitlab .navbar-collapse{flex:0 0 auto;border-top:0;padding:0}@media (max-width: 575.98px){.navbar-gitlab .navbar-collapse{flex:1 1 auto}}.navbar-gitlab .navbar-collapse .nav{flex-wrap:nowrap}@media (max-width: 575.98px){.navbar-gitlab .navbar-collapse .nav>li:not(.d-none) a{margin-left:0}}.navbar-gitlab .container-fluid{padding:0}.navbar-gitlab .container-fluid .user-counter svg{margin-right:3px}.navbar-gitlab .container-fluid .navbar-toggler{position:relative;right:-10px;border-radius:0;min-width:45px;padding:0;margin:8px 8px 8px 0;font-size:14px;text-align:center;color:currentColor}.navbar-gitlab .container-fluid .navbar-toggler.active{color:currentColor;background-color:transparent}@media (max-width: 575.98px){.navbar-gitlab .container-fluid .navbar-nav{display:flex;padding-right:10px;flex-direction:row}}.navbar-gitlab .container-fluid .navbar-nav li .badge.badge-pill:not(.gl-badge){box-shadow:none;font-weight:600}@media (max-width: 575.98px){.navbar-gitlab .container-fluid .nav>li.header-user{padding-left:10px}}.navbar-gitlab .container-fluid .nav>li>a{will-change:color;margin:4px 0;padding:6px 8px;height:32px}@media (max-width: 575.98px){.navbar-gitlab .container-fluid .nav>li>a{padding:0}}.navbar-gitlab .container-fluid .nav>li>a.header-user-dropdown-toggle{margin-left:2px}.navbar-gitlab .container-fluid .nav>li>a.header-user-dropdown-toggle .header-user-avatar{margin-right:0}.navbar-gitlab .container-fluid .nav>li .header-new-dropdown-toggle{margin-right:0}.navbar-sub-nav>li>a,.navbar-sub-nav>li>button,.navbar-nav>li>a,.navbar-nav>li>button{display:flex;align-items:center;justify-content:center;padding:6px 8px;margin:4px 2px;font-size:12px;color:currentColor;border-radius:4px;height:32px;font-weight:600}.navbar-sub-nav>li>a:active,.navbar-sub-nav>li>button:active,.navbar-nav>li>a:active,.navbar-nav>li>button:active{box-shadow:0 0 0 1px rgba(0,0,0,0.6),0 0 0 3px #63a6e9;outline:none}.navbar-sub-nav>li .top-nav-toggle,.navbar-sub-nav>li>button,.navbar-nav>li .top-nav-toggle,.navbar-nav>li>button{background:transparent;border:0}.navbar-sub-nav .dropdown-menu,.navbar-nav .dropdown-menu{position:absolute}.navbar-sub-nav{display:flex;align-items:center;height:100%;margin:0 0 0 6px}.caret-down,.btn .caret-down{top:0;height:11px;width:11px;margin-left:4px;fill:currentColor}.header-user .dropdown-menu,.header-new .dropdown-menu{margin-top:4px}.btn-sign-in{background-color:#ebebfa;color:#292961;font-weight:600;line-height:18px;margin:4px 0 4px 2px}@media (max-width: 575.98px){.navbar-gitlab .container-fluid{font-size:18px}.navbar-gitlab .container-fluid .navbar-nav{table-layout:fixed;width:100%;margin:0;text-align:right}.navbar-gitlab .container-fluid .navbar-collapse{margin-left:-8px;margin-right:-10px}.navbar-gitlab .container-fluid .navbar-collapse .nav>li:not(.d-none){flex:1}.header-user-dropdown-toggle{text-align:center}.header-user-avatar{float:none}}.header-user-avatar{float:left;margin-right:5px;border-radius:50%;border:1px solid #f5f5f5}.notification-dot{background-color:#d99530;height:12px;width:12px;pointer-events:none;visibility:hidden;top:3px}.tanuki-logo .tanuki{fill:#e24329}.tanuki-logo .left-cheek,.tanuki-logo .right-cheek{fill:#fc6d26}.tanuki-logo .chin{fill:#fca326}.context-header{position:relative;margin-right:2px;width:256px}.context-header>a,.context-header>button{font-weight:600;display:flex;width:100%;align-items:center;padding:10px 16px 10px 10px;color:#303030;background-color:transparent;border:0;text-align:left}.context-header .avatar-container{flex:0 0 32px;background-color:#fff}.context-header .sidebar-context-title{overflow:hidden;text-overflow:ellipsis;color:#303030}@media (min-width: 768px){.page-with-contextual-sidebar{padding-left:56px}}@media (min-width: 1200px){.page-with-contextual-sidebar{padding-left:256px}}@media (min-width: 768px){.page-with-icon-sidebar{padding-left:56px}}.nav-sidebar{position:fixed;bottom:0;left:0;z-index:600;width:256px;top:var(--header-height, 48px);background-color:#f5f5f5;border-right:1px solid #e9e9e9;transform:translate3d(0, 0, 0)}.nav-sidebar.sidebar-collapsed-desktop{width:56px}.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll{overflow-x:hidden}.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge),.nav-sidebar.sidebar-collapsed-desktop .nav-item-name,.nav-sidebar.sidebar-collapsed-desktop .collapse-text{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items>li>a{min-height:unset}.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item:not(.divider){display:block !important}.nav-sidebar.sidebar-collapsed-desktop .avatar-container{margin:0 auto}.nav-sidebar.sidebar-collapsed-desktop li.active:not(.fly-out-top-item)>a{background-color:rgba(41,41,97,0.08)}.nav-sidebar a{text-decoration:none;color:#303030}.nav-sidebar li{white-space:nowrap}.nav-sidebar li .nav-item-name{flex:1;overflow:hidden;text-overflow:ellipsis}.nav-sidebar li>a,.nav-sidebar li>.fly-out-top-item-container{padding-left:0.75rem;padding-right:0.75rem;padding-top:0.5rem;padding-bottom:0.5rem;display:flex;align-items:center;border-radius:0.25rem;width:auto;line-height:1rem;margin:1px 8px}.nav-sidebar li.active>a{font-weight:600}.nav-sidebar li.active:not(.fly-out-top-item)>a:not(.has-sub-items){background-color:rgba(0,0,0,0.08)}.nav-sidebar ul{padding-left:0;list-style:none}@media (max-width: 767.98px){.nav-sidebar{left:-256px}}.nav-sidebar .nav-icon-container{display:flex;margin-right:8px}.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item{display:none}.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item a,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item.active a,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container{margin-left:0;margin-right:0;padding-left:1rem;padding-right:1rem;cursor:default;pointer-events:none;font-size:0.75rem;margin-top:-0.25rem;margin-bottom:-0.25rem;margin-top:0;position:relative;color:#fff;background:var(--black, #000)}.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item a strong,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item.active a strong,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container strong{font-weight:400}.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item a::before,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item.active a::before,.nav-sidebar a:not(.has-sub-items)+.sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container::before{position:absolute;content:"";display:block;top:50%;left:-0.25rem;margin-top:-0.25rem;width:0;height:0;border-top:0.25rem solid transparent;border-bottom:0.25rem solid transparent;border-right:0.25rem solid #000;border-right-color:var(--black, #000)}@media (min-width: 576px){.nav-sidebar a.has-sub-items+.sidebar-sub-level-items{min-width:150px}}.nav-sidebar a.has-sub-items+.sidebar-sub-level-items .fly-out-top-item{display:none}.nav-sidebar a.has-sub-items+.sidebar-sub-level-items .fly-out-top-item a,.nav-sidebar a.has-sub-items+.sidebar-sub-level-items .fly-out-top-item.active a,.nav-sidebar a.has-sub-items+.sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container{margin-left:0;margin-right:0;padding-left:1rem;padding-right:1rem;cursor:default;pointer-events:none;font-size:0.75rem;margin-top:0;border-bottom-left-radius:0;border-bottom-right-radius:0}@media (min-width: 768px) and (max-width: 1199px){.nav-sidebar:not(.sidebar-expanded-mobile){width:56px}.nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll{overflow-x:hidden}.nav-sidebar:not(.sidebar-expanded-mobile) .badge.badge-pill:not(.fly-out-badge),.nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name,.nav-sidebar:not(.sidebar-expanded-mobile) .collapse-text{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items>li>a{min-height:unset}.nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item:not(.divider){display:block !important}.nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container{margin:0 auto}.nav-sidebar:not(.sidebar-expanded-mobile) li.active:not(.fly-out-top-item)>a{background-color:rgba(41,41,97,0.08)}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header{height:60px;width:56px}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header a{padding:10px 4px}.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header{height:auto}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header a{padding:0.25rem}.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items>li .sidebar-sub-level-items:not(.flyout-list){display:none}.nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container{margin-right:0}.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button{width:55px;padding:0 21px}.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .collapse-text{display:none}.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-left{transform:rotate(180deg);margin:0}}.nav-sidebar-inner-scroll{height:100%;width:100%;overflow-x:hidden;overflow-y:auto}.nav-sidebar-inner-scroll>div.context-header{margin-top:0.25rem}.nav-sidebar-inner-scroll>div.context-header a{padding-left:0.75rem;padding-right:0.75rem;padding-top:0.5rem;padding-bottom:0.5rem;display:flex;align-items:center;border-radius:0.25rem;width:auto;line-height:1rem;margin:1px 8px;padding:0.25rem;margin-bottom:0.25rem;margin-top:0.125rem}.nav-sidebar-inner-scroll>div.context-header a .avatar-container{font-weight:400;flex:none}.sidebar-top-level-items{margin-bottom:60px}.sidebar-top-level-items .context-header a{padding:0.25rem;margin-bottom:0.25rem;margin-top:0.125rem}.sidebar-top-level-items .context-header a .avatar-container{font-weight:400;flex:none}.sidebar-top-level-items>li.active .sidebar-sub-level-items:not(.is-fly-out-only){display:block}.sidebar-top-level-items li>a.gl-link{color:#303030}.sidebar-top-level-items li>a.gl-link:active{text-decoration:none}.sidebar-sub-level-items{padding-top:0;padding-bottom:0;display:none}.sidebar-sub-level-items:not(.fly-out-list) li>a{padding-left:2.25rem}.toggle-sidebar-button,.close-nav-button{height:48px;padding:0 16px;background-color:#fafafa;border:0;color:#666;display:flex;align-items:center;background-color:#f5f5f5;position:fixed;bottom:0;width:255px}.toggle-sidebar-button .collapse-text,.toggle-sidebar-button .icon-chevron-double-lg-left,.close-nav-button .collapse-text,.close-nav-button .icon-chevron-double-lg-left{color:inherit}.collapse-text{white-space:nowrap;overflow:hidden}.sidebar-collapsed-desktop .context-header{height:60px;width:56px}.sidebar-collapsed-desktop .context-header a{padding:10px 4px}.sidebar-collapsed-desktop .sidebar-context-title{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.sidebar-collapsed-desktop .context-header{height:auto}.sidebar-collapsed-desktop .context-header a{padding:0.25rem}.sidebar-collapsed-desktop .sidebar-top-level-items>li .sidebar-sub-level-items:not(.flyout-list){display:none}.sidebar-collapsed-desktop .nav-icon-container{margin-right:0}.sidebar-collapsed-desktop .toggle-sidebar-button{width:55px;padding:0 21px}.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text{display:none}.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left{transform:rotate(180deg);margin:0}.close-nav-button{display:none}@media (max-width: 767.98px){.close-nav-button{display:flex}.toggle-sidebar-button{display:none}}input::-moz-placeholder{color:#868686;opacity:1}input::-ms-input-placeholder{color:#868686}input:-ms-input-placeholder{color:#868686}svg{fill:currentColor}svg.s12{width:12px;height:12px}svg.s16{width:16px;height:16px}svg.s32{width:32px;height:32px}svg.s12{vertical-align:-1px}svg.s16{vertical-align:-3px}.header-content .header-search-new{max-width:640px}.header-search{min-width:320px}@media (min-width: 768px) and (max-width: 1199.98px){.header-search{min-width:200px}}.header-search .keyboard-shortcut-helper{transform:translateY(calc(50% - 2px));box-shadow:none;border-color:transparent}.search{margin:0 8px}.search form{display:block;margin:0;padding:4px;width:200px;line-height:24px;height:32px;border:0;border-radius:4px}@media (min-width: 1200px){.search form{width:320px}}.search .search-input{border:0;font-size:14px;padding:0 20px 0 0;margin-left:5px;line-height:25px;width:98%;color:#fff;background:none}.search .search-input-container{display:flex;position:relative}.search .search-input-wrap{width:100%}.search .search-input-wrap .search-icon,.search .search-input-wrap .clear-icon{position:absolute;right:5px;top:4px}.search .search-input-wrap .search-icon{-webkit-user-select:none;user-select:none}.search .search-input-wrap .clear-icon{display:none}.search .search-input-wrap .dropdown{position:static}.search .search-input-wrap .dropdown-menu{left:-5px;max-height:400px;overflow:auto}@media (min-width: 1200px){.search .search-input-wrap .dropdown-menu{width:320px}}.search .identicon{flex-basis:16px;flex-shrink:0;margin-right:4px}.avatar,.avatar-container{float:left;margin-right:16px;border-radius:50%}.avatar.s16,.avatar-container.s16{width:16px;height:16px;margin-right:8px}.avatar.s32,.avatar-container.s32{width:32px;height:32px;margin-right:8px}.avatar{transition-property:none;width:40px;height:40px;padding:0;background:#fdfdfd;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(31,31,31,0.1)}.avatar.avatar-tile{border-radius:0;border:0}.identicon{text-align:center;vertical-align:top;color:#303030;background-color:#f0f0f0}.identicon.s16{font-size:10px;line-height:16px}.identicon.s32{font-size:14px;line-height:32px}.identicon.bg1{background-color:#fcf1ef}.identicon.bg2{background-color:#f4f0ff}.identicon.bg3{background-color:#f1f1ff}.identicon.bg4{background-color:#e9f3fc}.identicon.bg5{background-color:#ecf4ee}.identicon.bg6{background-color:#fdf1dd}.identicon.bg7{background-color:#f0f0f0}.avatar-container{overflow:hidden;display:flex}.avatar-container a{width:100%;height:100%;display:flex;text-decoration:none}.avatar-container .avatar{border-radius:0;border:0;height:auto;width:100%;margin:0;align-self:center}.rect-avatar{border-radius:2px}.rect-avatar.s16{border-radius:2px}.rect-avatar.s16 .avatar{border-radius:2px}.rect-avatar.s32{border-radius:4px}.rect-avatar.s32 .avatar{border-radius:4px}.tab-width-8{tab-size:8}.gl-sr-only{border:0;clip:rect(0, 0, 0, 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.gl-border-none\!{border-style:none !important}.gl-display-none{display:none}.gl-display-flex{display:flex}@media (min-width: 576px){.gl-sm-display-block{display:block}}@media (min-width: 992px){.gl-lg-display-block{display:block}}.gl-display-inline-block\!{display:inline-block !important}.gl-align-items-center{align-items:center}.gl-align-items-stretch{align-items:stretch}.gl-flex-grow-1{flex-grow:1}.gl-justify-content-end{justify-content:flex-end}.gl-relative{position:relative}.gl-absolute{position:absolute}.gl-top-0{top:0}.gl-right-3{right:0.5rem}.gl-w-full{width:100%}.gl-px-3{padding-left:0.5rem;padding-right:0.5rem}.gl-pr-2{padding-right:0.25rem}.gl-pt-0{padding-top:0}.gl-mr-auto{margin-right:auto}.gl-mr-3{margin-right:0.5rem}.gl-ml-n2{margin-left:-0.25rem}.gl-ml-3{margin-left:0.5rem}.gl-mx-0\!{margin-left:0 !important;margin-right:0 !important}.gl-text-right{text-align:right}.gl-white-space-nowrap{white-space:nowrap}.gl-font-sm{font-size:0.75rem}.gl-font-weight-bold{font-weight:600}.gl-z-index-1{z-index:1}.cloak-startup,.content-wrapper>.alert-wrapper,#content-body,.modal-dialog{display:none}
-
-</style>
-
-
-<link rel="stylesheet" media="print" href="/assets/application-83d43ac2aff2d407da96a5fd6a410aa784c1ada12f2e8e02b328764d02324432.css" />
-
-<link rel="stylesheet" media="print" href="/assets/application_utilities-08432cf9120e4223aaf60df81aa67b3a688203198905c5ee86fc3c7e2133dd8b.css" />
-<link rel="stylesheet" media="all" href="/assets/disable_animations-3d7c8bec9ad25c81043c6c75ec12cd989c713cafd18037f1d311a6d293005d10.css" />
-<link rel="stylesheet" media="all" href="/assets/test_environment-17f80ceba7066f139c30ca70bbac619c342c7ab09bcc7c5ba0085aa4b072a50a.css" />
-<link rel="stylesheet" media="print" href="/assets/highlight/themes/white-557ba28a0d83a177dd5f4cdaa59e208f666e026683c63c59f494ece39cb34f98.css" />
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-document.querySelectorAll('link[media="print"]').forEach(linkTag => {
- linkTag.setAttribute('data-startupcss', 'loading');
- const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded');
- linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true});
-})
-
-//]]>
-</script>
-
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-window.gon={};gon.api_version="v4";gon.default_avatar_url="http://localhost/assets/no_avatar-849f9c04a3a0d0cea2424ae97b27447dc64a7dbfae83c036c45b403392f0e8ba.png";gon.max_file_size=10;gon.asset_host=null;gon.webpack_public_path="/assets/webpack/";gon.relative_url_root="";gon.user_color_scheme="white";gon.markdown_surround_selection=true;gon.markdown_automatic_lists=true;gon.recaptcha_api_server_url="https://www.google.com/recaptcha/api.js";gon.recaptcha_sitekey=null;gon.gitlab_url="http://localhost";gon.revision="67337076eae";gon.feature_category="global_search";gon.gitlab_logo="/assets/gitlab_logo-2957169c8ef64c58616a1ac3f4fc626e8a35ce4eb3ed31bb0d873712f2a041a0.png";gon.secure=false;gon.sprite_icons="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg";gon.sprite_file_icons="/assets/file_icons-958d18a1c33aa82a81e2eb1ffbffc33131d501c41ad95838a70b089e5ffbd7a0.svg";gon.emoji_sprites_css_path="/assets/emoji_sprites-e1b1ba2d7a86a445dcb1110d1b6e7dd0200ecaa993a445df77a07537dbf8f475.css";gon.select2_css_path="/assets/lazy_bundles/select2-972cb11866a2afb07749efdf63c646325d6ad61bac72ad794042166dcbecfc81.css";gon.test_env=true;gon.disable_animations=null;gon.suggested_label_colors={"#009966":"Green-cyan","#8fbc8f":"Dark sea green","#3cb371":"Medium sea green","#00b140":"Green screen","#013220":"Dark green","#6699cc":"Blue-gray","#0000ff":"Blue","#e6e6fa":"Lavender","#9400d3":"Dark violet","#330066":"Deep violet","#808080":"Gray","#36454f":"Charcoal grey","#f7e7ce":"Champagne","#c21e56":"Rose red","#cc338b":"Magenta-pink","#dc143c":"Crimson","#ff0000":"Red","#cd5b45":"Dark coral","#eee600":"Titanium yellow","#ed9121":"Carrot orange","#c39953":"Aztec Gold"};gon.first_day_of_week=0;gon.time_display_relative=true;gon.ee=true;gon.jh=false;gon.dot_com=false;gon.current_user_id=5;gon.current_username="user1";gon.current_user_fullname="Sidney Jones2";gon.current_user_avatar_url="https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=80\u0026d=identicon";gon.features={"usageDataApi":true,"securityAutoFix":true,"newHeaderSearch":true,"sourceEditorToolbar":true,"integrationSlackAppNotifications":true,"searchPageVerticalNav":false};gon.roadmap_epics_limit=1000;gon.subscriptions_url="https://customers.staging.gitlab.com";gon.payment_form_url="https://customers.staging.gitlab.com/payment_forms/cc_validation";gon.payment_validation_form_id="payment_method_validation";gon.registration_validation_form_url="https://customers.staging.gitlab.com/payment_forms/cc_registration_validation";
-//]]>
-</script>
-
-
-
-
-
-<script src="/assets/webpack/runtime.bundle.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/main.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/monaco.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-c71db27a.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-a2563177.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-e08f2dc5.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-177cacb5.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-ba62a390.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-439692c5.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-jira_connect_app-pages.projects.blob.show-pages.projects.show-pages.root-pages.searc-de960d17.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-pages.projects.blob.show-pages.projects.show-pages.root-pages.search.show-pages.sess-63a811d4.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/1.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/commons-default-pages.projects.blob.show-pages.projects.show-pages.root-pages.search.show-pages.sessions.new.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-<script src="/assets/webpack/pages.search.show.chunk.js" defer="defer" nonce="HoEnOW5qBBDwC4FvyjlrqA=="></script>
-
-<meta content="object" property="og:type">
-<meta content="GitLab" property="og:site_name">
-<meta content="term · Search · GitLab" property="og:title">
-<meta content="issues results for term &#39;term&#39;" property="og:description">
-<meta content="http://test.host/assets/twitter_card-570ddb06edf56a2312253c5872489847a0f385112ddbcd71ccfa1570febab5d2.jpg" property="og:image">
-<meta content="64" property="og:image:width">
-<meta content="64" property="og:image:height">
-<meta content="http://test.host/search.html?scope=issues&amp;search=term" property="og:url">
-<meta content="summary" property="twitter:card">
-<meta content="term · Search · GitLab" property="twitter:title">
-<meta content="issues results for term &#39;term&#39;" property="twitter:description">
-<meta content="http://test.host/assets/twitter_card-570ddb06edf56a2312253c5872489847a0f385112ddbcd71ccfa1570febab5d2.jpg" property="twitter:image">
-
-<meta content="issues results for term &#39;term&#39;" name="description">
-<link href="/-/manifest.json" rel="manifest">
-<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
-<meta content="#292961" name="theme-color">
-
-<meta name="csp-nonce" content="HoEnOW5qBBDwC4FvyjlrqA==" />
-<meta name="action-cable-url" content="/-/cable" />
-<link rel="apple-touch-icon" type="image/x-icon" href="/assets/apple-touch-icon-b049d4bc0dd9626f31db825d61880737befc7835982586d015bded10b4435460.png" />
-<link href="/search/opensearch.xml" rel="search" title="Search GitLab" type="application/opensearchdescription+xml">
-
-
-
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[];
-p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments)
-};p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1;
-n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","http://test.host/assets/snowplow/sp-871a2a8782c3dbf48f9f2bcc8642417934befe14cbfd7922f7e80e90d4cfe8f9.js","snowplow"));
-
-window.snowplowOptions = {"namespace":"gl","hostname":"localhost","cookieDomain":null,"appId":null,"formTracking":true,"linkClickTracking":true}
-
-gl = window.gl || {};
-gl.snowplowStandardContext = {"schema":"iglu:com.gitlab/gitlab_standard/jsonschema/1-0-8","data":{"environment":"development","source":"gitlab-rails","plan":null,"extra":{},"user_id":5,"namespace_id":null,"project_id":null,"context_generated_at":"2022-10-18T14:24:54.133Z"}}
-gl.snowplowPseudonymizedPageUrl = "http://localhost/search.html?scope=issues&search=masked_search";
-
-
-//]]>
-</script>
-
-</head>
-
-<body class="ui-indigo tab-width-8 gl-browser-generic gl-platform-other" data-page="search:show">
-
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-gl = window.gl || {};
-gl.client = {"isGeneric":true,"isOther":true};
-
-
-//]]>
-</script>
-
-
-
-<header class="navbar navbar-gitlab navbar-expand-sm js-navbar" data-qa-selector="navbar">
-<a class="gl-sr-only gl-accessibility" href="#content-body">Skip to content</a>
-<div class="container-fluid">
-<div class="header-content js-header-content">
-<div class="title-container hide-when-top-nav-responsive-open gl-transition-medium gl-display-flex gl-align-items-stretch gl-pt-0 gl-mr-3">
-<div class="title">
-<span class="gl-sr-only">GitLab</span>
-<a title="Dashboard" id="logo" class="has-tooltip" data-track-label="main_navigation" data-track-action="click_gitlab_logo_link" data-track-property="navigation" href="/"><svg class="tanuki-logo" width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path class="tanuki-shape tanuki" d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z"
- fill="#E24329"/>
- <path class="tanuki-shape right-cheek" d="m24.507 9.5-.034-.09a11.44 11.44 0 0 0-4.56 2.051l-7.447 5.632 4.742 3.584 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z"
- fill="#FC6D26"/>
- <path class="tanuki-shape chin" d="m7.707 20.677 2.56 1.935 1.555 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935-4.743-3.584-4.755 3.584Z"
- fill="#FCA326"/>
- <path class="tanuki-shape left-cheek" d="M5.01 11.461a11.43 11.43 0 0 0-4.56-2.05L.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 4.745-3.584-7.444-5.632Z"
- fill="#FC6D26"/>
-</svg>
-
-</a></div>
-<div class="gl-display-flex gl-align-items-center">
-</div>
-<div class="gl-display-none gl-sm-display-block">
-<ul class="list-unstyled nav navbar-sub-nav" data-view-model="{&quot;primary&quot;:[{&quot;type&quot;:&quot;header&quot;,&quot;title&quot;:&quot;Switch to&quot;},{&quot;id&quot;:&quot;project&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;project&quot;,&quot;href&quot;:&quot;&quot;,&quot;view&quot;:&quot;projects&quot;,&quot;css_class&quot;:&quot;qa-projects-dropdown&quot;,&quot;data&quot;:{&quot;track_label&quot;:&quot;projects_dropdown&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;groups&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;group&quot;,&quot;href&quot;:&quot;&quot;,&quot;view&quot;:&quot;groups&quot;,&quot;css_class&quot;:&quot;qa-groups-dropdown&quot;,&quot;data&quot;:{&quot;track_label&quot;:&quot;groups_dropdown&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;},&quot;emoji&quot;:null},{&quot;type&quot;:&quot;header&quot;,&quot;title&quot;:&quot;Explore&quot;},{&quot;id&quot;:&quot;milestones&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Milestones&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;clock&quot;,&quot;href&quot;:&quot;/dashboard/milestones&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;milestones_link&quot;,&quot;track_label&quot;:&quot;menu_milestones&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;snippets&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Snippets&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;snippet&quot;,&quot;href&quot;:&quot;/dashboard/snippets&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;snippets_link&quot;,&quot;track_label&quot;:&quot;menu_snippets&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;activity&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Activity&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;history&quot;,&quot;href&quot;:&quot;/dashboard/activity&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;activity_link&quot;,&quot;track_label&quot;:&quot;menu_activity&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;secondary&quot;:[],&quot;views&quot;:{&quot;projects&quot;:{&quot;namespace&quot;:&quot;projects&quot;,&quot;currentUserName&quot;:&quot;user1&quot;,&quot;currentItem&quot;:{},&quot;linksPrimary&quot;:[{&quot;id&quot;:&quot;your&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;View all projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/projects&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;View all projects&quot;,&quot;track_label&quot;:&quot;menu_view_all_projects&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;linksSecondary&quot;:[]},&quot;groups&quot;:{&quot;namespace&quot;:&quot;groups&quot;,&quot;currentUserName&quot;:&quot;user1&quot;,&quot;currentItem&quot;:{},&quot;linksPrimary&quot;:[{&quot;id&quot;:&quot;your&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;View all groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/groups&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;View all groups&quot;,&quot;track_label&quot;:&quot;menu_view_all_groups&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;linksSecondary&quot;:[]}},&quot;shortcuts&quot;:[{&quot;id&quot;:&quot;project-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/projects&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-projects&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Projects&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;groups-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/groups&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-groups&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Groups&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;milestones-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Milestones&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/milestones&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-milestones&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Milestones&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;snippets-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Snippets&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/snippets&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-snippets&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Snippets&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;activity-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Activity&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/activity&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-activity&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Activity&quot;},&quot;emoji&quot;:null}],&quot;menuTooltip&quot;:&quot;Main menu&quot;}" id="js-top-nav">
-<li>
-<a class="top-nav-toggle" data-toggle="dropdown" href="#" type="button">
-<svg class="s16" data-testid="hamburger-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#hamburger"></use></svg>
-</a>
-</li>
-</ul>
-<div class="hidden">
-<a class="dashboard-shortcuts-projects" href="/dashboard/projects">Projects
-</a><a class="dashboard-shortcuts-groups" href="/dashboard/groups">Groups
-</a><a class="dashboard-shortcuts-milestones" href="/dashboard/milestones">Milestones
-</a><a class="dashboard-shortcuts-snippets" href="/dashboard/snippets">Snippets
-</a><a class="dashboard-shortcuts-activity" href="/dashboard/activity">Activity
-</a></div>
-
-</div>
-</div>
-<div class="navbar-collapse gl-transition-medium collapse gl-mr-auto global-search-container hide-when-top-nav-responsive-open">
-<ul class="nav navbar-nav gl-w-full gl-align-items-center">
-<li class="nav-item header-search-new gl-display-none gl-lg-display-block gl-w-full">
-</li>
-<li class="nav-item d-none d-sm-inline-block d-lg-none">
-<a title="Search" aria-label="Search" data-toggle="tooltip" data-placement="bottom" data-container="body" href="/search"><svg class="s16" data-testid="search-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#search"></use></svg>
-</a></li>
-</ul>
-</div>
-<div class="navbar-collapse gl-transition-medium collapse">
-<ul class="nav navbar-nav gl-w-full gl-align-items-center gl-justify-content-end">
-<li class="header-new gl-flex-grow-1 gl-flex-shrink-1 dropdown gl-display-none gl-sm-display-block gl-white-space-nowrap gl-text-right" data-track-action="click_dropdown" data-track-label="new_dropdown">
-<a class="header-new-dropdown-toggle has-tooltip gl-display-inline-block!" id="js-onboarding-new-project-link" title="Create new..." ref="tooltip" aria-label="Create new..." data-toggle="dropdown" data-placement="bottom" data-container="body" data-display="static" data-qa-selector="new_menu_toggle" href="/projects/new"><svg class="s16" data-testid="plus-square-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#plus-square"></use></svg>
-<svg class="s16 caret-down" data-testid="chevron-down-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-down"></use></svg>
-</a><div class="dropdown-menu dropdown-menu-right dropdown-extended-height">
-<ul>
-<li><a data-track-action="click_link_new_project" data-track-label="plus_menu_dropdown" data-qa-selector="global_new_project_link" href="/projects/new">New project/repository</a></li>
-<li><a data-track-action="click_link_new_group" data-track-label="plus_menu_dropdown" data-qa-selector="global_new_group_link" href="/groups/new">New group</a></li>
-<li><a data-track-action="click_link_new_snippet_parent" data-track-label="plus_menu_dropdown" data-qa-selector="global_new_snippet_link" href="/-/snippets/new">New snippet</a></li>
-</ul>
-</div>
-</li>
-
-<li class="user-counter"><a title="Issues" class="dashboard-shortcuts-issues js-prefetch-document" aria-label="Issues" data-qa-selector="issues_shortcut_button" data-toggle="tooltip" data-placement="bottom" data-track-label="main_navigation" data-track-action="click_issues_link" data-track-property="navigation" data-container="body" href="/dashboard/issues?assignee_username=user1"><svg class="s16" data-testid="issues-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#issues"></use></svg>
-<span aria-label="0 assigned issues" class="gl-badge badge badge-pill badge-success sm gl-ml-n2 gl-display-none">0
-</span></a></li><li class="user-counter dropdown"><a class="dashboard-shortcuts-merge_requests has-tooltip" title="Merge requests" aria-label="Merge requests" data-qa-selector="merge_requests_shortcut_button" data-toggle="dropdown" data-placement="bottom" data-track-label="main_navigation" data-track-action="click_merge_link" data-track-property="navigation" data-container="body" href="/dashboard/merge_requests?assignee_username=user1"><svg class="s16" data-testid="git-merge-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#git-merge"></use></svg>
-<span aria-label="0 merge requests" class="gl-badge badge badge-pill badge-warning sm js-merge-requests-count gl-ml-n2 gl-display-none">0
-</span><svg class="s16 caret-down gl-mx-0!" data-testid="chevron-down-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-down"></use></svg>
-</a><div class="dropdown-menu dropdown-menu-right">
-<ul>
-<li class="dropdown-header">
-Merge requests
-</li>
-<li>
-<a class="gl-display-flex! gl-align-items-center js-prefetch-document" href="/dashboard/merge_requests?assignee_username=user1">Assigned to you
-<span class="gl-badge badge badge-pill badge-neutral sm js-assigned-mr-count gl-ml-auto">0
-</span></a></li>
-<li>
-<a class="gl-display-flex! gl-align-items-center js-prefetch-document" href="/dashboard/merge_requests?reviewer_username=user1">Review requests for you
-<span class="gl-badge badge badge-pill badge-neutral sm js-reviewer-mr-count gl-ml-auto">0
-</span></a></li>
-</ul>
-</div>
-</li><li class="user-counter"><a title="To-Do List" aria-label="To-Do List" class="shortcuts-todos js-prefetch-document" data-qa-selector="todos_shortcut_button" data-toggle="tooltip" data-placement="bottom" data-track-label="main_navigation" data-track-action="click_to_do_link" data-track-property="navigation" data-container="body" href="/dashboard/todos"><svg class="s16" data-testid="todo-done-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#todo-done"></use></svg>
-<span aria-label="Todos count" class="gl-badge badge badge-pill badge-info sm js-todos-count gl-ml-n2 hidden">0
-</span></a></li><li class="nav-item header-help dropdown d-none d-md-block" data-track-action="click_question_mark_link" data-track-experiment="cross_stage_fdm" data-track-label="main_navigation" data-track-property="navigation">
-<a class="header-help-dropdown-toggle gl-relative" data-toggle="dropdown" href="/help"><span class="gl-sr-only">
-Help
-</span>
-<svg class="s16" data-testid="question-o-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#question-o"></use></svg>
-<span class="notification-dot rounded-circle gl-absolute"></span>
-<svg class="s16 caret-down" data-testid="chevron-down-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-down"></use></svg>
-</a><div class="dropdown-menu dropdown-menu-right">
-<ul>
-<li>
-
-</li>
-
-<li>
-<button class="gl-justify-content-space-between gl-align-items-center js-whats-new-trigger gl-display-flex!" type="button">
-What&#39;s new
-<span class="gl-badge badge badge-pill badge-muted sm js-whats-new-notification-count">4</span>
-</button>
-</li>
-
-<li>
-<a href="/help">Help</a>
-</li>
-<li>
-<a href="https://about.gitlab.com/getting-help/">Support</a>
-</li>
-<li>
-<a target="_blank" class="text-nowrap" rel="noopener noreferrer" data-track-action="click_forum" data-track-property="question_menu" href="https://forum.gitlab.com">Community forum</a>
-
-</li>
-<li>
-<button class="js-shortcuts-modal-trigger" type="button">
-Keyboard shortcuts
-<kbd aria-hidden="true" class="flat float-right">?</kbd>
-</button>
-</li>
-<li class="divider"></li>
-<li>
-<a href="https://about.gitlab.com/submit-feedback">Submit feedback</a>
-</li>
-<li>
-
-</li>
-
-</ul>
-
-</div>
-</li>
-<li class="nav-item header-user js-nav-user-dropdown dropdown" data-qa-selector="user_menu" data-testid="user-menu" data-track-action="click_dropdown" data-track-label="profile_dropdown" data-track-value="">
-<a class="header-user-dropdown-toggle" data-toggle="dropdown" href="/user1"><img srcset="https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=48&amp;d=identicon 1x, https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=48&amp;d=identicon 2x" alt="Sidney Jones2" class="gl-avatar gl-avatar-s24 header-user-avatar gl-avatar-circle" height="24" width="24" loading="lazy" data-qa-selector="user_avatar_content" src="https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=48&amp;d=identicon" />
-
-
-<svg class="s16 caret-down" data-testid="chevron-down-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-down"></use></svg>
-</a><div class="dropdown-menu dropdown-menu-right">
-<ul>
-<li class="current-user">
-<a class="gl-line-height-20!" data-user="user1" data-testid="user-profile-link" data-qa-selector="user_profile_link" href="/user1"><div class="gl-font-weight-bold">
-Sidney Jones2
-</div>
-@user1
-
-</a></li>
-<li class="divider"></li>
-<li>
-<button class="gl-button btn btn-link menu-item js-set-status-modal-trigger" type="button">
-Set status
-</button>
-</li>
-<li>
-<a data-qa-selector="edit_profile_link" href="/-/profile">Edit profile</a>
-</li>
-<li>
-<a href="/-/profile/preferences">Preferences</a>
-</li>
-
-<li class="divider d-md-none"></li>
-<li class="d-md-none">
-<a href="/help">Help</a>
-</li>
-<li class="d-md-none">
-<a href="https://about.gitlab.com/getting-help/">Support</a>
-</li>
-<li class="d-md-none">
-<a target="_blank" class="text-nowrap" rel="noopener noreferrer" data-track-action="click_forum" data-track-property="question_menu" href="https://forum.gitlab.com">Community forum</a>
-
-</li>
-<li class="d-md-none">
-<a href="https://about.gitlab.com/submit-feedback">Submit feedback</a>
-</li>
-<li class="d-md-none">
-
-</li>
-
-<li class="divider"></li>
-<li>
-<a class="sign-out-link" data-qa-selector="sign_out_link" rel="nofollow" data-method="post" href="/users/sign_out">Sign out</a>
-</li>
-</ul>
-
-</div>
-</li>
-</ul>
-</div>
-<button class="navbar-toggler d-block d-sm-none gl-border-none!" data-qa-selector="mobile_navbar_button" data-testid="top-nav-responsive-toggle" type="button">
-<span class="sr-only">Toggle navigation</span>
-<span class="more-icon gl-px-3 gl-font-sm gl-font-weight-bold">
-<span class="gl-pr-2">Menu</span>
-<svg class="s16" data-testid="hamburger-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#hamburger"></use></svg>
-</span>
-<svg class="s12 close-icon" data-testid="close-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#close"></use></svg>
-</button>
-</div>
-</div>
-</header>
-<div data-version-digest="b0774e3a70a678d0f08ccf2ca4c3dcfc3ef576b938236bb4e7951682a80b3086" id="whats-new-app"></div>
-<div class="js-set-status-modal-wrapper" data-current-emoji="" data-current-message="" data-default-emoji="speech_balloon"></div>
-
-<div class="layout-page hide-when-top-nav-responsive-open">
-<div class="content-wrapper content-wrapper-margin">
-<div class="mobile-overlay"></div>
-
-<div class="alert-wrapper gl-force-block-formatting-context">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-</div>
-<div class="container-fluid container-limited ">
-<main class="content" id="content-body">
-<div class="flash-container flash-container-page sticky" data-qa-selector="flash_container">
-</div>
-
-
-
-<div class="page-title-holder gl-display-flex gl-flex-wrap gl-justify-content-space-between">
-<h1 class="page-title gl-font-size-h-display gl-mr-5">Search</h1>
-
-</div>
-<div class="gl-mt-3">
-<div data-group-initial-data="null" data-project-initial-data="null" id="js-search-topbar"></div>
-</div>
-<div class="scrolling-tabs-container inner-page-scroll-tabs is-smaller">
-<div class="fade-left"><svg class="s12" data-testid="chevron-lg-left-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-lg-left"></use></svg></div>
-<div class="fade-right"><svg class="s12" data-testid="chevron-lg-right-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#chevron-lg-right"></use></svg></div>
-<ul class="search-filter scrolling-tabs nav-links nav gl-tabs-nav"><li data-qa-selector="projects_tab"><a href="/search?scope=projects&amp;search=term">Projects <span class="gl-badge badge badge-pill badge-muted sm js-search-count hidden" data-url="/search/count?scope=projects&amp;search=term"></span></a></li>
-
-
-<li class="active"><a href="/search?scope=issues&amp;search=term">Issues <span class="gl-badge badge badge-pill badge-muted sm">0</span></a></li>
-<li><a href="/search?scope=merge_requests&amp;search=term">Merge requests <span class="gl-badge badge badge-pill badge-muted sm js-search-count hidden" data-url="/search/count?scope=merge_requests&amp;search=term"></span></a></li>
-
-
-<li><a href="/search?scope=milestones&amp;search=term">Milestones <span class="gl-badge badge badge-pill badge-muted sm js-search-count hidden" data-url="/search/count?scope=milestones&amp;search=term"></span></a></li>
-<li><a href="/search?scope=users&amp;search=term">Users <span class="gl-badge badge badge-pill badge-muted sm js-search-count hidden" data-url="/search/count?scope=users&amp;search=term"></span></a></li>
-
-</ul></div>
-
-
-
-<div class="results gl-md-display-flex gl-mt-3">
-<div class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4" data-navigation="{&quot;projects&quot;:{&quot;label&quot;:&quot;Projects&quot;,&quot;scope&quot;:&quot;projects&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;projects_tab&quot;},&quot;link&quot;:&quot;/search?scope=projects&amp;search=term&quot;,&quot;active&quot;:false,&quot;count_link&quot;:&quot;/search/count?scope=projects&amp;search=term&quot;},&quot;blobs&quot;:{&quot;label&quot;:&quot;Code&quot;,&quot;scope&quot;:&quot;blobs&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;code_tab&quot;},&quot;link&quot;:&quot;/search?scope=blobs&amp;search=term&quot;,&quot;active&quot;:false,&quot;count_link&quot;:&quot;/search/count?scope=blobs&amp;search=term&quot;},&quot;issues&quot;:{&quot;label&quot;:&quot;Issues&quot;,&quot;scope&quot;:&quot;issues&quot;,&quot;data&quot;:null,&quot;link&quot;:&quot;/search?scope=issues&amp;search=term&quot;,&quot;active&quot;:true,&quot;count&quot;:&quot;0&quot;},&quot;merge_requests&quot;:{&quot;label&quot;:&quot;Merge requests&quot;,&quot;scope&quot;:&quot;merge_requests&quot;,&quot;data&quot;:null,&quot;link&quot;:&quot;/search?scope=merge_requests&amp;search=term&quot;,&quot;active&quot;:false,&quot;count_link&quot;:&quot;/search/count?scope=merge_requests&amp;search=term&quot;},&quot;milestones&quot;:{&quot;label&quot;:&quot;Milestones&quot;,&quot;scope&quot;:&quot;milestones&quot;,&quot;data&quot;:null,&quot;link&quot;:&quot;/search?scope=milestones&amp;search=term&quot;,&quot;active&quot;:false,&quot;count_link&quot;:&quot;/search/count?scope=milestones&amp;search=term&quot;},&quot;users&quot;:{&quot;label&quot;:&quot;Users&quot;,&quot;scope&quot;:&quot;users&quot;,&quot;data&quot;:null,&quot;link&quot;:&quot;/search?scope=users&amp;search=term&quot;,&quot;active&quot;:false,&quot;count_link&quot;:&quot;/search/count?scope=users&amp;search=term&quot;}}" id="js-search-sidebar"></div>
-<div class="gl-w-full gl-flex-grow-1 gl-overflow-x-hidden">
-<div class="search_box gl-my-8 gl-text-center">
-<div class="search_glyph"></div>
-<h4>
-<svg class="s24 gl-vertical-align-text-bottom" data-testid="search-icon"><use href="/assets/icons-7dd47f0bd545c11d13acc0f4abc2ce8fac4abbbef0965fde375eb13c62e7f9fd.svg#search"></use></svg>
-We couldn&#39;t find any issues matching <code>term</code>
-</h4>
-</div>
-
-
-</div>
-</div>
-
-
-</main>
-</div>
-
-
-</div>
-</div>
-<div class="top-nav-responsive layout-page content-wrapper-margin">
-<div class="cloak-startup">
-<div data-view-model="{&quot;primary&quot;:[{&quot;type&quot;:&quot;header&quot;,&quot;title&quot;:&quot;Switch to&quot;},{&quot;id&quot;:&quot;project&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;project&quot;,&quot;href&quot;:&quot;&quot;,&quot;view&quot;:&quot;projects&quot;,&quot;css_class&quot;:&quot;qa-projects-dropdown&quot;,&quot;data&quot;:{&quot;track_label&quot;:&quot;projects_dropdown&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;groups&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;group&quot;,&quot;href&quot;:&quot;&quot;,&quot;view&quot;:&quot;groups&quot;,&quot;css_class&quot;:&quot;qa-groups-dropdown&quot;,&quot;data&quot;:{&quot;track_label&quot;:&quot;groups_dropdown&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;},&quot;emoji&quot;:null},{&quot;type&quot;:&quot;header&quot;,&quot;title&quot;:&quot;Explore&quot;},{&quot;id&quot;:&quot;milestones&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Milestones&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;clock&quot;,&quot;href&quot;:&quot;/dashboard/milestones&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;milestones_link&quot;,&quot;track_label&quot;:&quot;menu_milestones&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;snippets&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Snippets&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;snippet&quot;,&quot;href&quot;:&quot;/dashboard/snippets&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;snippets_link&quot;,&quot;track_label&quot;:&quot;menu_snippets&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;activity&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Activity&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;history&quot;,&quot;href&quot;:&quot;/dashboard/activity&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;activity_link&quot;,&quot;track_label&quot;:&quot;menu_activity&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;secondary&quot;:[],&quot;views&quot;:{&quot;projects&quot;:{&quot;namespace&quot;:&quot;projects&quot;,&quot;currentUserName&quot;:&quot;user1&quot;,&quot;currentItem&quot;:{},&quot;linksPrimary&quot;:[{&quot;id&quot;:&quot;your&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;View all projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/projects&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;View all projects&quot;,&quot;track_label&quot;:&quot;menu_view_all_projects&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;linksSecondary&quot;:[]},&quot;groups&quot;:{&quot;namespace&quot;:&quot;groups&quot;,&quot;currentUserName&quot;:&quot;user1&quot;,&quot;currentItem&quot;:{},&quot;linksPrimary&quot;:[{&quot;id&quot;:&quot;your&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;View all groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/groups&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;View all groups&quot;,&quot;track_label&quot;:&quot;menu_view_all_groups&quot;,&quot;track_action&quot;:&quot;click_dropdown&quot;,&quot;track_property&quot;:&quot;navigation&quot;},&quot;emoji&quot;:null}],&quot;linksSecondary&quot;:[]},&quot;new&quot;:{&quot;title&quot;:&quot;Create new...&quot;,&quot;menu_sections&quot;:[{&quot;title&quot;:&quot;GitLab&quot;,&quot;menu_items&quot;:[{&quot;id&quot;:&quot;general_new_project&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;New project/repository&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/projects/new&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;track_action&quot;:&quot;click_link_new_project&quot;,&quot;track_label&quot;:&quot;plus_menu_dropdown&quot;,&quot;qa_selector&quot;:&quot;global_new_project_link&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;general_new_group&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;New group&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/groups/new&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;track_action&quot;:&quot;click_link_new_group&quot;,&quot;track_label&quot;:&quot;plus_menu_dropdown&quot;,&quot;qa_selector&quot;:&quot;global_new_group_link&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;general_new_snippet&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;New snippet&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/-/snippets/new&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;track_action&quot;:&quot;click_link_new_snippet_parent&quot;,&quot;track_label&quot;:&quot;plus_menu_dropdown&quot;,&quot;qa_selector&quot;:&quot;global_new_snippet_link&quot;},&quot;emoji&quot;:null}]}]},&quot;search&quot;:{&quot;id&quot;:&quot;search&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Search&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;search&quot;,&quot;href&quot;:&quot;/search&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:null,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Search&quot;},&quot;emoji&quot;:null}},&quot;shortcuts&quot;:[{&quot;id&quot;:&quot;project-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Projects&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/projects&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-projects&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Projects&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;groups-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Groups&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/groups&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-groups&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Groups&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;milestones-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Milestones&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/milestones&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-milestones&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Milestones&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;snippets-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Snippets&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/snippets&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-snippets&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Snippets&quot;},&quot;emoji&quot;:null},{&quot;id&quot;:&quot;activity-shortcut&quot;,&quot;type&quot;:&quot;item&quot;,&quot;title&quot;:&quot;Activity&quot;,&quot;active&quot;:false,&quot;icon&quot;:&quot;&quot;,&quot;href&quot;:&quot;/dashboard/activity&quot;,&quot;view&quot;:&quot;&quot;,&quot;css_class&quot;:&quot;dashboard-shortcuts-activity&quot;,&quot;data&quot;:{&quot;qa_selector&quot;:&quot;menu_item_link&quot;,&quot;qa_title&quot;:&quot;Activity&quot;},&quot;emoji&quot;:null}],&quot;menuTooltip&quot;:&quot;Main menu&quot;}" id="js-top-nav-responsive"></div>
-</div>
-</div>
-
-
-
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-if ('loading' in HTMLImageElement.prototype) {
- document.querySelectorAll('img.lazy').forEach(img => {
- img.loading = 'lazy';
- let imgUrl = img.dataset.src;
- // Only adding width + height for avatars for now
- if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
- const targetWidth = img.getAttribute('width') || img.width;
- imgUrl += `?width=${targetWidth}`;
- }
- img.src = imgUrl;
- img.removeAttribute('data-src');
- img.classList.remove('lazy');
- img.classList.add('js-lazy-loaded', 'qa-js-lazy-loaded');
- });
-}
-
-//]]>
-</script>
-<script nonce="HoEnOW5qBBDwC4FvyjlrqA==">
-//<![CDATA[
-gl = window.gl || {};
-gl.experiments = {};
-
-
-//]]>
-</script>
-
-</body>
-</html>
-