Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-12-12 12:33:40 +00:00
parent 37f03adfda
commit c80f596324
89 changed files with 1115 additions and 1896 deletions

View File

@ -453,7 +453,6 @@ Layout/ClassStructure:
- 'qa/qa/support/parallel_pipeline_jobs.rb'
- 'qa/qa/tools/ci/test_metrics.rb'
- 'qa/qa/tools/migrate_influx_data_to_gcs.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'rubocop/cop/migration/ensure_factory_for_table.rb'
- 'rubocop/cop/rails/migration_timestamp.rb'
- 'rubocop/cop_todo.rb'

View File

@ -57,7 +57,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'ee/app/components/namespaces/free_user_cap/enforcement_at_limit_alert_component.rb'
- 'ee/app/components/namespaces/free_user_cap/usage_quota_alert_component.rb'
- 'ee/app/components/namespaces/free_user_cap/usage_quota_trial_alert_component.rb'
- 'ee/app/components/namespaces/storage/limit_alert_component.rb'
- 'ee/app/components/namespaces/storage/repository_limit_alert_component.rb'
- 'ee/app/controllers/concerns/insights_actions.rb'
- 'ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb'
@ -422,7 +421,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'qa/qa/specs/features/ee/browser_ui/10_govern/export_vulnerability_report_spec.rb'
- 'qa/qa/specs/features/ee/browser_ui/9_data_stores/elasticsearch/elasticsearch_reindexing_spec.rb'
- 'qa/qa/support/system_logs/kibana.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'qa/qa/tools/revoke_user_personal_access_tokens.rb'
- 'qa/qa/tools/test_resources_handler.rb'
- 'qa/spec/support/system_logs/kibana_spec.rb'

View File

@ -234,7 +234,6 @@ Lint/UnusedBlockArgument:
- 'qa/qa/specs/features/api/12_systems/gitaly/distributed_reads_spec.rb'
- 'qa/qa/support/matchers/eventually_matcher.rb'
- 'qa/qa/tools/generate_perf_testdata.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'qa/tasks/vulnerabilities.rake'
- 'scripts/docs_screenshots.rb'
- 'scripts/packages/automated_cleanup.rb'

View File

@ -141,9 +141,7 @@ Rails/Date:
- 'qa/qa/specs/features/ee/browser_ui/12_systems/geo/wiki_ssh_push_to_secondary_spec.rb'
- 'qa/qa/tools/delete_resource_base.rb'
- 'qa/qa/tools/delete_test_users.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'qa/qa/tools/revoke_user_personal_access_tokens.rb'
- 'qa/spec/tools/reliable_report_spec.rb'
- 'spec/controllers/admin/users_controller_spec.rb'
- 'spec/controllers/groups/milestones_controller_spec.rb'
- 'spec/controllers/sessions_controller_spec.rb'

View File

@ -57,7 +57,6 @@ Rails/OutputSafety:
- 'ee/app/components/namespaces/free_user_cap/non_owner_enforcement_alert_component.rb'
- 'ee/app/components/namespaces/free_user_cap/usage_quota_alert_component.rb'
- 'ee/app/components/namespaces/free_user_cap/usage_quota_trial_alert_component.rb'
- 'ee/app/components/namespaces/storage/limit_alert_component.rb'
- 'ee/app/components/namespaces/storage/repository_limit_alert_component.rb'
- 'ee/app/controllers/ee/projects/issues_controller.rb'
- 'ee/app/controllers/ee/repositories/lfs_api_controller.rb'

View File

@ -229,7 +229,6 @@ RSpec/ExampleWithoutDescription:
- 'ee/spec/workers/security/scan_execution_policies/rule_schedule_worker_spec.rb'
- 'qa/qa/specs/features/browser_ui/9_data_stores/project/dashboard_images_spec.rb'
- 'qa/spec/specs/helpers/feature_flag_spec.rb'
- 'qa/spec/tools/reliable_report_spec.rb'
- 'spec/bin/audit_event_type_spec.rb'
- 'spec/bin/feature_flag_spec.rb'
- 'spec/bin/saas_feature_spec.rb'

View File

@ -83,7 +83,6 @@ RSpec/NoExpectationExample:
- 'spec/features/projects/files/undo_template_spec.rb'
- 'spec/features/user_settings/ssh_keys_spec.rb'
- 'spec/features/users/login_spec.rb'
- 'spec/features/work_items/linked_work_items_spec.rb'
- 'spec/frontend/fixtures/issues.rb'
- 'spec/frontend/fixtures/listbox.rb'
- 'spec/frontend/fixtures/merge_requests.rb'

View File

@ -3,7 +3,6 @@
RSpec/ReceiveMessages:
Exclude:
- 'ee/spec/bin/custom_ability_spec.rb'
- 'ee/spec/components/namespaces/storage/limit_alert_component_spec.rb'
- 'ee/spec/components/namespaces/storage/repository_limit_alert_component_spec.rb'
- 'ee/spec/controllers/concerns/analytics/cycle_analytics/value_stream_actions_spec.rb'
- 'ee/spec/controllers/concerns/audit_events/audit_events_params_spec.rb'
@ -167,7 +166,6 @@ RSpec/ReceiveMessages:
- 'ee/spec/services/merge_requests/mergeability/check_path_locks_service_spec.rb'
- 'ee/spec/services/resource_access_tokens/create_service_spec.rb'
- 'ee/spec/services/search/elastic/cluster_reindexing_service_spec.rb'
- 'ee/spec/services/search/index_repair_service_spec.rb'
- 'ee/spec/services/search/project_service_spec.rb'
- 'ee/spec/services/search/rake_task_executor_service_spec.rb'
- 'ee/spec/services/search_service_spec.rb'

View File

@ -102,7 +102,6 @@ RSpec/VerifiedDoubleReference:
- 'qa/spec/tools/ci/code_paths_mapping_spec.rb'
- 'qa/spec/tools/ci/test_metrics_spec.rb'
- 'qa/spec/tools/knapsack_report_updater_spec.rb'
- 'qa/spec/tools/reliable_report_spec.rb'
- 'spec/benchmarks/banzai_benchmark.rb'
- 'spec/commands/sidekiq_cluster/cli_spec.rb'
- 'spec/components/pajamas/concerns/checkbox_radio_label_with_help_text_spec.rb'

View File

@ -91,7 +91,6 @@ Style/HashEachMethods:
- 'lib/tasks/gitlab/db.rake'
- 'lib/tasks/gitlab/setup.rake'
- 'qa/qa/specs/features/ee/browser_ui/10_govern/change_vulnerability_status_spec.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'qa/spec/specs/runner_spec.rb'
- 'scripts/generate_rspec_pipeline.rb'
- 'spec/config/settings_spec.rb'

View File

@ -2137,7 +2137,6 @@ Style/InlineDisableAnnotation:
- 'qa/qa/support/page_error_checker.rb'
- 'qa/qa/support/run.rb'
- 'qa/qa/support/wait_for_requests.rb'
- 'qa/qa/tools/reliable_report.rb'
- 'qa/spec/ee/resource/mixins/group_base_spec.rb'
- 'qa/spec/page/base_spec.rb'
- 'qa/spec/resource/project_web_hook_spec.rb'

View File

@ -3,7 +3,7 @@ import { uniq } from 'lodash';
import { transformAstToDisplayFields } from '../transformer/ast';
import { parseFields } from './fields';
export const parseConfig = (frontmatter, defaults = {}) => {
export const parseYAMLConfig = (frontmatter, defaults = {}) => {
const config = jsYaml.safeLoad(frontmatter) || {};
const parsedFields = transformAstToDisplayFields(
parseFields(config.fields || defaults?.fields.join(',')),

View File

@ -1,9 +1,10 @@
import { parseConfig } from './config';
import jsYaml from 'js-yaml';
import { parseYAMLConfig } from './config';
import { parseQuery } from './query';
const DEFAULT_DISPLAY_FIELDS = ['title'];
export const parseQueryText = (text) => {
export const parseQueryTextWithFrontmatter = (text) => {
const frontmatter = text.match(/---\n([\s\S]*?)\n---/);
const remaining = text.replace(frontmatter ? frontmatter[0] : '', '');
return {
@ -12,9 +13,17 @@ export const parseQueryText = (text) => {
};
};
const isValidYAML = (text) => typeof jsYaml.safeLoad(text) === 'object';
export const parse = async (glqlQuery, target = 'graphql') => {
const { frontmatter, query } = parseQueryText(glqlQuery);
const config = parseConfig(frontmatter, { fields: DEFAULT_DISPLAY_FIELDS });
let { frontmatter: config, query } = parseQueryTextWithFrontmatter(glqlQuery);
if (!config && isValidYAML(glqlQuery)) {
// if frontmatter isn't present, query is a part of the config
({ query, ...config } = parseYAMLConfig(glqlQuery, { fields: DEFAULT_DISPLAY_FIELDS }));
} else {
config = parseYAMLConfig(config, { fields: DEFAULT_DISPLAY_FIELDS });
}
const limit = parseInt(config.limit, 10) || undefined;
return { query: await parseQuery(query, { ...config, target, limit }), config };

View File

@ -3,14 +3,12 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { makeLoadCandidatesErrorMessage, NO_CANDIDATES_LABEL } from '../translations';
import getModelCandidatesQuery from '../graphql/queries/get_model_candidates.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '../constants';
import SearchableList from './searchable_list.vue';
import CandidateListRow from './candidate_list_row.vue';
import SearchableTable from './searchable_table.vue';
export default {
name: 'MlCandidateList',
components: {
CandidateListRow,
SearchableList,
SearchableTable,
},
props: {
modelId: {
@ -74,21 +72,16 @@ export default {
};
</script>
<template>
<div>
<searchable-list
:page-info="pageInfo"
:items="items"
:error-message="errorMessage"
:is-loading="isLoading"
@fetch-page="fetchPage"
>
<template #empty-state>
{{ $options.i18n.NO_CANDIDATES_LABEL }}
</template>
<template #item="{ item }">
<candidate-list-row :candidate="item" />
</template>
</searchable-list>
</div>
<searchable-table
:candidates="items"
:page-info="pageInfo"
:error-message="errorMessage"
:is-loading="isLoading"
:can-write-model-registry="false"
@fetch-page="fetchPage"
>
<template #empty-state>
{{ $options.i18n.NO_CANDIDATES_LABEL }}
</template>
</searchable-table>
</template>

View File

@ -0,0 +1,63 @@
<script>
import { GlTableLite, GlLink, GlBadge } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { s__ } from '~/locale';
export default {
name: 'CandidatesTable',
components: {
GlTableLite,
TimeAgoTooltip,
GlLink,
GlBadge,
},
props: {
items: {
type: Array,
required: false,
default: () => [],
},
},
methods: {
statusBadge(status) {
return {
success: 'success',
failed: 'danger',
pending: 'info',
running: 'info',
canceled: 'warning',
}[status];
},
hasCiJob(item) {
return item.ciJob;
},
},
tableFields: [
{ key: 'eid', label: s__('ModelRegistry|MLflow Run ID'), thClass: 'gl-w-2/8' },
{ key: 'ciJob', label: s__('ModelRegistry|CI Job'), thClass: 'gl-w-1/8' },
{ key: 'createdAt', label: s__('ModelRegistry|Created'), thClass: 'gl-w-1/8' },
{ key: 'status', label: s__('ModelRegistry|Status'), thClass: 'gl-w-1/8' },
],
};
</script>
<template>
<gl-table-lite class="fixed" :items="items" :fields="$options.tableFields" stacked="sm">
<template #cell(eid)="{ item }">
<gl-link :href="item._links.showPath">
{{ item.eid }}
</gl-link>
</template>
<template #cell(ciJob)="{ item }">
<span v-if="hasCiJob(item)">{{ item.ciJob.name }}</span>
</template>
<template #cell(createdAt)="{ item: { createdAt } }">
<time-ago-tooltip v-if="createdAt" :time="createdAt" />
</template>
<template #cell(status)="{ item }">
<gl-badge :variant="statusBadge(item.status)">
{{ item.status }}
</gl-badge>
</template>
</gl-table-lite>
</template>

View File

@ -7,6 +7,7 @@ import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_ba
import LoadOrErrorOrShow from '~/ml/model_registry/components/load_or_error_or_show.vue';
import ModelsTable from '~/ml/model_registry/components/models_table.vue';
import ModelVersionsTable from '~/ml/model_registry/components/model_versions_table.vue';
import CandidatesTable from '~/ml/model_registry/components/candidates_table.vue';
export default {
name: 'SearchableTable',
@ -16,6 +17,7 @@ export default {
GlKeysetPagination,
ModelsTable,
ModelVersionsTable,
CandidatesTable,
},
directives: {
GlTooltip,
@ -31,6 +33,11 @@ export default {
required: false,
default: () => [],
},
candidates: {
type: Array,
required: false,
default: () => [],
},
pageInfo: {
type: Object,
required: true,
@ -77,15 +84,6 @@ export default {
};
},
computed: {
isModelVersionsEmpty() {
return this.modelVersions.length === 0;
},
isModelsEmpty() {
return this.models.length === 0;
},
isListEmpty() {
return this.isModelVersionsEmpty && this.isModelsEmpty;
},
parsedQuery() {
const name = this.filters
.map((f) => f.value.data)
@ -161,19 +159,20 @@ export default {
@filter:clear="filters = []"
/>
<load-or-error-or-show :is-loading="isLoading" :error-message="errorMessage">
<slot v-if="isListEmpty" name="empty-state"></slot>
<model-versions-table
v-else-if="!isModelVersionsEmpty"
v-if="modelVersions.length"
:items="modelVersions"
can-write-model-registry
@model-versions-update="submitFilters"
/>
<models-table
v-else-if="!isModelsEmpty"
v-else-if="models.length"
:items="models"
can-write-model-registry
@models-update="submitFilters"
/>
<candidates-table v-else-if="candidates.length" :items="candidates" />
<slot v-else name="empty-state"></slot>
<gl-keyset-pagination
v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
v-bind="pageInfo"

View File

@ -11,7 +11,13 @@ query getModelCandidates(
count
nodes {
id
eid
ciJob {
id
name
}
name
status
createdAt
_links {
showPath

View File

@ -158,7 +158,7 @@ export default {
@mouseleave="$emit('mouseleave')"
>
<ul
class="gl-min-w-20 gl-max-w-34 gl-list-none gl-rounded-base gl-border-1 gl-border-solid gl-border-default gl-bg-white gl-p-2 gl-pb-1 gl-shadow-md"
class="gl-min-w-20 gl-max-w-34 gl-list-none gl-rounded-base gl-border-1 gl-border-solid gl-border-default gl-bg-overlap gl-p-2 gl-pb-1 gl-shadow-md"
@mouseenter="showSVG = false"
>
<nav-item

View File

@ -145,8 +145,8 @@ export default {
{{ item.title }}
</span>
<span class="gl-text-right gl-text-gray-400">
<gl-icon class="super-sidebar-mix-blend-mode" :name="collapseIcon" />
<span class="gl-text-right">
<gl-icon class="super-sidebar-mix-blend-mode" :name="collapseIcon" variant="subtle" />
</span>
</button>

View File

@ -353,51 +353,53 @@ export default {
<div
class="vue-filtered-search-bar-container gl-flex gl-min-w-0 gl-flex-col sm:gl-flex-row sm:gl-gap-3"
>
<gl-form-checkbox
v-if="showCheckbox"
class="gl-self-center"
:checked="checkboxChecked"
@change="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ __('Select all') }}</span>
</gl-form-checkbox>
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="filteredRecentSearches"
:suggestions-list-class="suggestionsListClass"
:search-button-attributes="searchButtonAttributes"
:search-input-attributes="searchInputAttributes"
:recent-searches-header="__('Recent searches')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
:search-text-option-label="searchTextOptionLabel"
:show-friendly-text="showFriendlyText"
:show-search-button="showSearchButton"
:terms-as-tokens="termsAsTokens"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit"
@input="onInput"
>
<template #history-item="{ historyItem }">
<template v-for="(token, index) in historyItem">
<span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
<span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1">
<span v-if="tokenTitles[token.type]"
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
>
<strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong>
</span>
<div class="flex-grow-1 gl-flex gl-gap-3">
<gl-form-checkbox
v-if="showCheckbox"
class="gl-min-h-0 gl-self-center"
:checked="checkboxChecked"
@change="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ __('Select all') }}</span>
</gl-form-checkbox>
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="filteredRecentSearches"
:suggestions-list-class="suggestionsListClass"
:search-button-attributes="searchButtonAttributes"
:search-input-attributes="searchInputAttributes"
:recent-searches-header="__('Recent searches')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
:search-text-option-label="searchTextOptionLabel"
:show-friendly-text="showFriendlyText"
:show-search-button="showSearchButton"
:terms-as-tokens="termsAsTokens"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit"
@input="onInput"
>
<template #history-item="{ historyItem }">
<template v-for="(token, index) in historyItem">
<span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
<span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1">
<span v-if="tokenTitles[token.type]"
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
>
<strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong>
</span>
</template>
</template>
</template>
</gl-filtered-search>
</gl-filtered-search>
</div>
<gl-sorting
v-if="selectedSortOption"
:sort-options="transformedSortOptions"

View File

@ -344,6 +344,7 @@ export default {
>
<gl-form-checkbox
v-if="showCheckbox"
class="gl-pr-3 gl-pt-2"
:checked="checked"
:data-id="issuableId"
:data-iid="issuableIid"

View File

@ -65,7 +65,7 @@
border: 0;
background-color: var(--rd-expand-lines-button-background-color, $gray-100);
color: var(--rd-expand-lines-button-color, $gray-700);
color: var(--rd-expand-lines-button-color, var(--gl-text-color-subtle));
&:hover,
&:focus {

View File

@ -176,7 +176,7 @@ table {
.loading {
margin: 20px auto;
height: 40px;
color: $gray-700;
@apply gl-text-subtle;
font-size: 32px;
text-align: center;
}

View File

@ -51,7 +51,8 @@ $crud-header-min-height: px-to-rem(49px);
}
table.b-table-stacked-sm,
table.b-table-stacked-md {
table.b-table-stacked-md,
table.b-table-stacked-lg {
margin-bottom: 0;
tr:first-of-type th {
@ -78,6 +79,12 @@ $crud-header-min-height: px-to-rem(49px);
@include new-card-table-adjustments;
}
}
table.gl-table.b-table.b-table-stacked-lg {
@include gl-media-breakpoint-down(lg) {
@include new-card-table-adjustments;
}
}
}
.crud-body:has(.content-list) {

View File

@ -629,7 +629,7 @@
padding-bottom: 0;
padding-left: $gl-spacing-scale-3;
padding-right: $gl-spacing-scale-3;
color: $gray-700;
@apply gl-text-subtle;
line-height: 30px;
border: 1px solid var(--gl-control-border-color-default);
border-radius: $gl-border-radius-base;
@ -641,7 +641,7 @@
&:hover {
~ .dropdown-input-clear {
color: $gray-700;
@apply gl-text-subtle;
}
}
}

View File

@ -833,15 +833,3 @@
font-size: 13px;
}
}
@include media-breakpoint-down(sm) {
// Overriding the following rule with the negative margin
// https://gitlab.com/gitlab-org/gitlab/-/blob/146c43c931c3743a140529307aea616e4aa9ff21/app/assets/stylesheets/framework/sidebar.scss#L1-5
.container-fluid {
.issuable-list,
.issues-filters,
.epics-filters {
margin: 0 (-$gl-padding);
}
}
}

View File

@ -24,7 +24,7 @@
max-width: 900px;
margin: 0 auto 10px;
}
.div-dropzone {
textarea.note-textarea {
resize: none !important;
@ -39,7 +39,7 @@
.zen-control {
padding: 0;
color: $gray-700;
@apply gl-text-subtle;
background: none;
border: 0;
}

View File

@ -39,7 +39,7 @@
}
.badge.badge-pill {
color: var(--ide-text-color, $gray-700);
color: var(--ide-text-color, var(--gl-icon-color-subtle));
background-color: var(--ide-background, $badge-bg);
}

View File

@ -74,7 +74,7 @@
}
.milestone {
color: var(--gray-700, $gray-700);
@apply gl-text-subtle;
}
}

View File

@ -57,7 +57,7 @@
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
color: var(--gray-700, $gray-700);
@apply gl-text-subtle;
}
.deploy-key {

View File

@ -67,7 +67,7 @@
.settings-header {
position: relative;
padding: $gl-spacing-scale-5 110px 0 0;
padding: $gl-spacing-scale-5 $gl-spacing-scale-7 0 0;
h4 {
margin-top: 0;
@ -94,7 +94,7 @@
position: relative;
max-height: 1px;
overflow-y: hidden;
padding-right: 110px;
@apply gl-pr-7;
animation: collapseMaxHeight 300ms ease-out;
// Keep the section from expanding when we scroll over it
pointer-events: none;

View File

@ -25,7 +25,7 @@
.save-group-loader {
margin-top: $gl-spacing-scale-9;
margin-bottom: $gl-spacing-scale-9;
color: $gray-700;
@apply gl-text-subtle;
}
.card {

View File

@ -45,7 +45,7 @@
position: relative;
font-size: inherit;
color: $gray-700;
@apply gl-text-subtle;
padding-top: $gl-spacing-scale-3;
padding-bottom: $gl-spacing-scale-3;
padding-left: $gl-spacing-scale-6;

View File

@ -89,11 +89,9 @@ class PagesDeployment < ApplicationRecord
end
def url
base_url = ::Gitlab::Pages::UrlBuilder
.new(project)
::Gitlab::Pages::UrlBuilder
.new(project, { path_prefix: path_prefix.to_s })
.pages_url
File.join(base_url.to_s, path_prefix.to_s)
end
def deactivate

View File

@ -13,6 +13,10 @@ module Projects
validate :validate_protected_branch_not_wildcard
validate :validate_protected_branch_belongs_to_project
def self.declarative_policy_class
'Projects::BranchRulePolicy'
end
private
def validate_protected_branch_not_wildcard

View File

@ -2548,9 +2548,13 @@ class User < ApplicationRecord
true
end
# Deprecated method. We are currently transitioning to the use of composite_identity_enforced attribute
def has_composite_identity?
false
# Since this is called in a number of places in both Sidekiq and Web,
# be extra paranoid that this column exists before reading it. This check
# can be removed in GitLab 17.8 or later.
return false unless has_attribute?(:composite_identity_enforced)
composite_identity_enforced
end
def composite_identity_enforced

View File

@ -2,7 +2,7 @@
require 'resolv'
class VerifyPagesDomainService < BaseService
class VerifyPagesDomainService
# The maximum number of seconds to be spent on each DNS lookup
RESOLVER_TIMEOUT_SECONDS = 15
@ -17,7 +17,9 @@ class VerifyPagesDomainService < BaseService
end
def execute
return error("No verification code set for #{domain.domain}") unless domain.verification_code.present?
unless domain.verification_code.present?
return ServiceResponse.error(message: "No verification code set for #{domain.domain}")
end
if !verification_enabled? || dns_record_present?
verify_domain!
@ -48,7 +50,7 @@ class VerifyPagesDomainService < BaseService
after_successful_verification
success
ServiceResponse.success
end
def after_successful_verification
@ -64,7 +66,7 @@ class VerifyPagesDomainService < BaseService
notify(:verification_failed) if was_verified
error("Couldn't verify #{domain.domain}")
ServiceResponse.error(message: "Couldn't verify #{domain.domain}")
end
def disable_domain!
@ -74,7 +76,7 @@ class VerifyPagesDomainService < BaseService
notify(:disabled)
error("Couldn't verify #{domain.domain}. It is now disabled.")
ServiceResponse.error(message: "Couldn't verify #{domain.domain}. It is now disabled.")
end
# A domain is only expired until `disable!` has been called
@ -118,7 +120,7 @@ class VerifyPagesDomainService < BaseService
return unless verification_enabled?
Gitlab::AppLogger.info("Pages domain '#{domain.domain}' changed state to '#{type}'")
notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend
NotificationService.new.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend -- Technical debt
end
end

View File

@ -4,7 +4,7 @@
= s_("ProtectedBranch|There are currently no protected branches, to protect a branch start by creating a new one above.")
- else
.flash-container
%table.table.b-table.gl-table.b-table-stacked-md
%table.table.b-table.gl-table.b-table-stacked-lg
%colgroup
%col{ width: "30%" }
%col{ width: "20%" }

View File

@ -19,7 +19,7 @@
= render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-close gl-hidden md:gl-inline-flex' }) do
= _('Close milestone')
- else
= render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'gl-hidden md:gl-inline-block' }) do
= render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'gl-hidden md:gl-inline-flex' }) do
= _('Reopen milestone')
.js-vue-milestone-actions{ data: { id: milestone.id,

View File

@ -1,9 +0,0 @@
---
name: admin_duo_page_configuration_settings
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/493383
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/168702
rollout_issue_url:
milestone: '17.7'
group: group::ai framework
type: wip
default_enabled: false

View File

@ -1,9 +0,0 @@
---
name: group_duo_page_configuration_settings
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/493383
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/168581
rollout_issue_url:
milestone: '17.7'
group: group::ai framework
type: wip
default_enabled: false

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddAssignDuoSeatsToSamlGroupLinks < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '17.7'
def up
add_column :saml_group_links, :assign_duo_seats, :boolean, null: false, default: false, if_not_exists: true
end
def down
remove_column :saml_group_links, :assign_duo_seats
end
end

View File

@ -0,0 +1 @@
f2da82c29d69c98179828263b4a12faccbbb41d816d2b939ad93c8db7782d158

View File

@ -19403,6 +19403,7 @@ CREATE TABLE saml_group_links (
updated_at timestamp with time zone NOT NULL,
saml_group_name text NOT NULL,
member_role_id bigint,
assign_duo_seats boolean DEFAULT false NOT NULL,
CONSTRAINT check_1b3fc49d1e CHECK ((char_length(saml_group_name) <= 255))
);

View File

@ -4,34 +4,28 @@ group: Import and Integrate
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
# Project integration administration
# Integration administration
DETAILS:
**Tier:** Free, Premium, Ultimate
**Offering:** Self-managed
**Offering:** Self-managed, GitLab Dedicated
NOTE:
This page contains administrator documentation for project integrations. For user documentation, see [Project integrations](../../user/project/integrations/index.md).
This page contains administrator documentation for project and group integrations. For user documentation, see [Project integrations](../../user/project/integrations/index.md).
Project integrations can be configured and enabled by project administrators. As a GitLab instance
administrator, you can set default configuration parameters for a given integration that all projects
can inherit and use, enabling the integration for all projects that are not already using custom
settings.
Project and group administrators can configure and enable integrations.
As an instance administrator, you can:
You can update these default settings at any time, changing the settings used for all projects that
are set to use instance-level or group-level defaults. Updating the default settings also enables the integration
for all projects that didn't have it already enabled.
- Set default configuration parameters for an integration.
- Configure an allowlist to control which integrations can be enabled on a GitLab instance.
Only the entire settings for an integration can be inherited. Per-field inheritance
is proposed in [epic 2137](https://gitlab.com/groups/gitlab-org/-/epics/2137).
## Manage instance-level default settings for a project integration
## Configure default settings for an integration
Prerequisites:
- You must have administrator access to the instance.
To manage instance-level default settings for a project integration:
To configure default settings for an integration:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Integrations**.
@ -66,13 +60,13 @@ instead of the instance-level settings.
Only the entire settings for an integration can be inherited. Per-field inheritance
is proposed in [epic 2137](https://gitlab.com/groups/gitlab-org/-/epics/2137).
### Remove an instance-level default setting
### Remove default settings for an integration
Prerequisites:
- You must have administrator access to the instance.
To remove an instance-level default setting:
To remove default settings for an integration:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Integrations**.
@ -93,3 +87,49 @@ To view projects in your instance that [use custom settings](../../user/project/
1. Select **Settings > Integrations**.
1. Select an integration.
1. Select the **Projects using custom settings** tab.
## Integration allowlist
DETAILS:
**Tier:** Ultimate
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/500610) in GitLab 17.7.
By default, project and group administrators can enable integrations.
However, instance administrators can configure an allowlist to control
which integrations can be enabled on a GitLab instance.
Enabled integrations that are later blocked by the allowlist settings are disabled.
If these integrations are allowed again, they are re-enabled with their existing configuration.
If you configure an empty allowlist, no integrations are allowed on the instance.
After you configure an allowlist, new GitLab integrations are not on the allowlist by default.
### Allow some integrations
Prerequisites:
- You must have administrator access to the instance.
To allow only integrations on the allowlist:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > General**.
1. Expand the **Integration settings** section.
1. Select **Allow only integrations on this allowlist**.
1. Select the checkbox for each integration you want to allow on the instance.
1. Select **Save changes**.
### Allow all integrations
Prerequisites:
- You must have administrator access to the instance.
To allow all integrations on a GitLab instance:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > General**.
1. Expand the **Integration settings** section.
1. Select **Allow all integrations**.
1. Select **Save changes**.

View File

@ -19908,6 +19908,7 @@ Branch rules configured for a rule target.
| <a id="branchruleisprotected"></a>`isProtected` | [`Boolean!`](#boolean) | Check if this branch rule protects access for the branch. |
| <a id="branchrulematchingbranchescount"></a>`matchingBranchesCount` | [`Int!`](#int) | Number of existing branches that match this branch rule. |
| <a id="branchrulename"></a>`name` | [`String!`](#string) | Name of the branch rule target. Includes wildcards. |
| <a id="branchrulesquashoption"></a>`squashOption` **{warning-solid}** | [`SquashOption`](#squashoption) | **Introduced** in GitLab 17.7. **Status**: Experiment. The default behavior for squashing in merge requests. Returns null if `branch_rule_squash_settings` feature flag is disabled. |
| <a id="branchruleupdatedat"></a>`updatedAt` | [`Time`](#time) | Timestamp of when the branch rule was last updated. |
### `BurnupChartDailyTotals`
@ -34972,6 +34973,17 @@ Represents the Geo sync and verification state of a snippet repository.
| <a id="snippetrepositoryregistryverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Verification state of the SnippetRepositoryRegistry. |
| <a id="snippetrepositoryregistryverifiedat"></a>`verifiedAt` | [`Time`](#time) | Timestamp of the most recent successful verification of the SnippetRepositoryRegistry. |
### `SquashOption`
Squash option overrides for a protected branch.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="squashoptionhelptext"></a>`helpText` | [`String!`](#string) | Help text for the squash option. |
| <a id="squashoptionoption"></a>`option` | [`String!`](#string) | Human-readable description of the squash option. |
### `SshSignature`
SSH signature for a signed commit.

View File

@ -775,16 +775,13 @@ GET /projects/:id/integrations/external-wiki
DETAILS:
**Tier:** Premium, Ultimate
**Offering:** Self-managed, GitLab Dedicated
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/435706) in GitLab 16.9 [with a flag](../administration/feature_flags.md) named `git_guardian_integration`. Enabled by default. Disabled on GitLab.com.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/438695#note_2226917025) in GitLab 17.7.
> - `use_inherited_settings` parameter [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467089) in GitLab 17.2 [with a flag](../administration/feature_flags.md) named `integration_api_inheritance`. Disabled by default.
> - `use_inherited_settings` parameter [generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/467186) in GitLab 17.3. Feature flag `integration_api_inheritance` removed.
FLAG:
On self-managed GitLab, by default this feature is available. To hide the feature, ask an administrator to [disable the feature flag](../administration/feature_flags.md) named `git_guardian_integration`.
On GitLab.com, this feature is not available. On GitLab Dedicated, this feature is available.
[GitGuardian](https://www.gitguardian.com/) is a cybersecurity service that detects sensitive data such as API keys
and passwords in source code repositories.
It scans Git repositories, alerts on policy violations, and helps organizations

View File

@ -480,7 +480,7 @@ DETAILS:
**Tier:** Free, Premium, Ultimate
**Offering:** Self-managed, GitLab Dedicated
See the [User tokens API](user_tokens.md#create-a-personal-access-token-with-limited-scopes-for-your-account) for
See the [User tokens API](user_tokens.md#create-a-personal-access-token) for
information on creating a personal access token for the currently authenticated user.
## Troubleshooting access tokens

View File

@ -14,7 +14,7 @@ Use this API to perform follower actions for user accounts. For more information
## Follow a user
Follow a user.
Follow a given user account.
```plaintext
POST /users/:id/follow
@ -24,7 +24,7 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:------------|
| `id` | integer | yes | ID of the user to follow |
| `id` | integer | yes | ID of user account |
Example request:
@ -48,7 +48,7 @@ Example response:
## Unfollow a user
Unfollow a user.
Unfollow a given user account.
```plaintext
POST /users/:id/unfollow
@ -58,7 +58,7 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:------------|
| `id` | integer | yes | ID of the user to unfollow |
| `id` | integer | yes | ID of user account |
Example request:
@ -66,9 +66,9 @@ Example request:
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/users/3/unfollow"
```
## Get the followers of a user
## List all accounts that follow a user
Get the followers of a user.
Lists all users accounts that follow a given user.
```plaintext
GET /users/:id/followers
@ -78,7 +78,7 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:------------|
| `id` | integer | yes | ID of the user |
| `id` | integer | yes | ID of user account |
Example request:
@ -111,9 +111,9 @@ Example response:
]
```
## Get the users that a user is following
## List all accounts followed by a user
Get the list of users being followed by a user.
Lists all users accounts being followed by a given user.
```plaintext
GET /users/:id/following
@ -123,7 +123,7 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:------------|
| `id` | integer | yes | ID of the user |
| `id` | integer | yes | ID of user account |
Example request:

View File

@ -12,16 +12,17 @@ DETAILS:
Use this API to interact with personal access tokens and impersonation tokens. For more information, see [personal access tokens](../user/profile/personal_access_tokens.md) and [impersonation tokens](rest/authentication.md#impersonation-tokens).
## Create a personal access token
## Create a personal access token for a user
> - The `expires_at` attribute default was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120213) in GitLab 16.0.
Create a new personal access token. Token values are returned once so, make sure you save it because you can't access it
again.
Creates a personal access token for a given user.
Token values are included with the response, but cannot be retrieved later.
Prerequisites:
- You must be an administrator.
- You must have administrator access to the instance.
```plaintext
POST /users/:user_id/personal_access_tokens
@ -31,11 +32,11 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:-------------|:--------|:---------|:------------|
| `user_id` | integer | yes | ID of the user. |
| `name` | string | yes | Name of the personal access token. |
| `description`| string | no | Description of the personal access token. |
| `expires_at` | date | no | Expiration date of the access token in ISO format (`YYYY-MM-DD`). If no date is set, the expiration is set to the [maximum allowable lifetime of an access token](../user/profile/personal_access_tokens.md#access-token-expiration). |
| `scopes` | array | yes | Array of scopes of the personal access token. See [personal access token scopes](../user/profile/personal_access_tokens.md#personal-access-token-scopes) for possible values. |
| `user_id` | integer | yes | ID of user account |
| `name` | string | yes | Name of personal access token |
| `description`| string | no | Description of personal access token |
| `expires_at` | date | no | Expiration date of the access token in ISO format (`YYYY-MM-DD`). If undefined, the date is set to the [maximum allowable lifetime limit](../user/profile/personal_access_tokens.md#access-token-expiration). |
| `scopes` | array | yes | Array of approved scopes. For a list of possible values, see [Personal access token scopes](../user/profile/personal_access_tokens.md#personal-access-token-scopes): |
Example request:
@ -63,23 +64,21 @@ Example response:
}
```
## Create a personal access token with limited scopes for your account
## Create a personal access token
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131923) in GitLab 16.5.
Create a new personal access token for your account.
Prerequisites:
- You must be authenticated.
For security purposes, the token:
Creates a personal access token for your account. For security purposes, the token:
- Is limited to the [`k8s_proxy` scope](../user/profile/personal_access_tokens.md#personal-access-token-scopes).
This scope grants permission to perform Kubernetes API calls using the agent for Kubernetes.
- By default, expires at the end of the day it was created on.
Token values are returned once, so make sure you save the token because you cannot access it again.
Token values are included with the response, but cannot be retrieved later.
Prerequisites:
- You must be authenticated.
```plaintext
POST /user/personal_access_tokens
@ -89,10 +88,10 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:-------------|:-------|:---------|:------------|
| `name` | string | yes | Name of the personal access token. |
| `description`| string | no | Description of the personal access token. |
| `scopes` | array | yes | Array of scopes of the personal access token. Possible values are `k8s_proxy`. |
| `expires_at` | array | no | Expiration date of the access token in ISO format (`YYYY-MM-DD`). If no date is set, the expiration is at the end of the current day. The expiration is subject to the [maximum allowable lifetime of an access token](../user/profile/personal_access_tokens.md#access-token-expiration). |
| `name` | string | yes | Name of personal access token |
| `description`| string | no | Description of personal access token |
| `scopes` | array | yes | Array of approved scopes. Only accepts `k8s_proxy`. |
| `expires_at` | array | no | Expiration date of the access token in ISO format (`YYYY-MM-DD`). If undefined, token expires at the end of the current day. Subject to the [maximum allowable lifetime limits](../user/profile/personal_access_tokens.md#access-token-expiration). |
Example request:
@ -119,14 +118,15 @@ Example response:
}
```
## Get all impersonation tokens of a user
## List all impersonation tokens for a user
Retrieve every impersonation token of a user. Use the [pagination parameters](rest/index.md#offset-based-pagination)
`page` and `per_page` to restrict the list of impersonation tokens.
Lists all impersonation tokens for a given user.
Use the `page` and `per_page` [pagination parameters](rest/index.md#offset-based-pagination) to filter the results.
Prerequisites:
- You must be an administrator.
- You must have administrator access to the instance.
```plaintext
GET /users/:user_id/impersonation_tokens
@ -136,8 +136,8 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:----------|:--------|:---------|:------------|
| `user_id` | integer | yes | ID of the user. |
| `state` | string | no | Filter tokens based on state: `all`, `active`, or `inactive`. |
| `user_id` | integer | yes | ID of user account |
| `state` | string | no | Filter tokens based on state. Possible values: `all`, `active`, or `inactive`. |
Example request:
@ -182,13 +182,13 @@ Example response:
]
```
## Get an impersonation token of a user
## Get an impersonation token for a user
Get a user's impersonation token.
Gets an impersonation token for a given user.
Prerequisites:
- You must be an administrator.
- You must have administrator access to the instance.
```plaintext
GET /users/:user_id/impersonation_tokens/:impersonation_token_id
@ -198,8 +198,8 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:-------------------------|:--------|:---------|:------------|
| `user_id` | integer | yes | ID of the user. |
| `impersonation_token_id` | integer | yes | ID of the impersonation token. |
| `user_id` | integer | yes | ID of user account |
| `impersonation_token_id` | integer | yes | ID of impersonation token |
Example request:
@ -228,14 +228,13 @@ Example response:
## Create an impersonation token
Create a new impersonation token. You can only create impersonation tokens to impersonate the user and perform
both API calls and Git reads and writes. The user can't see these tokens in their profile settings page.
Creates an impersonation token for a given user. These tokens are used to act on behalf of a user and can perform API calls as well as Git read and write actions. These tokens are not visible to the associated user on their profile settings page.
Token values are returned once. Make sure you save it because you can't access it again.
Token values are included with the response, but cannot be retrieved later.
Prerequisites:
- You must be an administrator.
- You must have administrator access to the instance.
```plaintext
POST /users/:user_id/impersonation_tokens
@ -245,11 +244,11 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:-------------|:--------|:---------|:------------|
| `user_id` | integer | yes | ID of the user. |
| `name` | string | yes | Name of the impersonation token. |
| `description`| string | no | Description of the personal access token. |
| `expires_at` | date | yes | Expiration date of the impersonation token in ISO format (`YYYY-MM-DD`). |
| `scopes` | array | yes | Array of scopes of the impersonation token (`api`, `read_user`). |
| `user_id` | integer | yes | ID of user account |
| `name` | string | yes | Name of impersonation token |
| `description`| string | no | Description of impersonation token |
| `expires_at` | date | yes | Expiration date of the impersonation token in ISO format (`YYYY-MM-DD`) |
| `scopes` | array | yes | Array of approved scopes. For a list of possible values, see [Personal access token scopes](../user/profile/personal_access_tokens.md#personal-access-token-scopes): |
Example request:
@ -280,11 +279,11 @@ Example response:
## Revoke an impersonation token
Revoke an impersonation token.
Revokes an impersonation token for a given user.
Prerequisites:
- You must be an administrator.
- You must have administrator access to the instance.
```plaintext
DELETE /users/:user_id/impersonation_tokens/:impersonation_token_id
@ -294,8 +293,8 @@ Supported attributes:
| Attribute | Type | Required | Description |
|:-------------------------|:--------|:---------|:------------|
| `user_id` | integer | yes | ID of the user. |
| `impersonation_token_id` | integer | yes | ID of the impersonation token. |
| `user_id` | integer | yes | ID of user account |
| `impersonation_token_id` | integer | yes | ID of impersonation token |
Example request:

View File

@ -40,7 +40,7 @@ Prerequisites:
You can enable the Jira issues integration by configuring your project settings in GitLab.
You can also configure the integration for a specific
[group](../../user/project/integrations/index.md#manage-group-default-settings-for-a-project-integration) or an entire
[instance](../../administration/settings/project_integration_management.md#manage-instance-level-default-settings-for-a-project-integration)
[instance](../../administration/settings/project_integration_management.md#configure-default-settings-for-an-integration)
on self-managed GitLab.
With this integration, your GitLab project can interact with all Jira projects on your instance.

View File

@ -29,13 +29,34 @@ NOTE:
Subscriptions cannot be transferred between GitLab.com and GitLab self-managed.
A new subscription must be purchased and applied as needed.
## Choose a GitLab tier
## Choose a subscription tier
Pricing is [tier-based](https://about.gitlab.com/pricing/), allowing you to choose
the features that fit your budget.
For more details, see [a comparison of features available in each tier](https://about.gitlab.com/pricing/feature-comparison/).
## Choose a subscription add-on
An add-on is an additional paid feature or service that you can purchase on top of an existing
GitLab subscription. Add-ons provide extra functionality or resources to enhance your GitLab
experience.
You can purchase the following add-ons:
- [GitLab Duo](subscription-add-ons.md): Get access to AI-powered features like Code Suggestions, GitLab
Duo Chat, and more.
- [Enterprise Agile Planning](gitlab_com/index.md#enterprise-agile-planning): Increase collaboration between
technical and non-technical teams on a single platform. Non-engineering team members can participate in planning,
measure impact with Value Stream Analytics, and gain visibility into software development velocity.
- [Storage](gitlab_com/index.md#purchase-more-storage): Buy more storage when you exceed your
free 10 GiB storage quota.
- [Compute minutes](gitlab_com/compute_minutes.md): Buy additional compute minutes when your
plan exceeds its allocated amount and you need to continue running automated
builds, tests, and deployments without interruption.
Some add-ons are only available to specific subscription tiers and offerings.
## Contact Support
- See the tiers of [GitLab Support](https://about.gitlab.com/support/).

View File

@ -125,9 +125,28 @@ When GitLab Duo is turned off for a group, project, or instance:
::Tabs
:::TabTitle In 17.4 and later
:::TabTitle In 17.7 and later
In GitLab 17.4 and later, follow these instructions to turn off GitLab Duo
In GitLab 17.7 and later, follow these instructions to turn off GitLab Duo
for a group and its subgroups and projects.
Prerequisites:
- You must have the Owner role for the group.
To turn off GitLab Duo for a group:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > GitLab Duo**.
1. Select **Change configuration**.
1. Choose an option:
- To turn off GitLab Duo for the group, but let other groups or projects turn it on, select **Off by default**.
- To turn off GitLab Duo for the group, and to prevent other groups or projects from turning it on, select **Always off**.
1. Select **Save changes**.
:::TabTitle In 17.4 to 17.6
In GitLab 17.4 to 17.6, follow these instructions to turn off GitLab Duo
for a group and its subgroups and projects.
Prerequisites:
@ -200,9 +219,27 @@ DETAILS:
::Tabs
:::TabTitle In 17.4 and later
:::TabTitle In 17.7 and later
In GitLab 17.4 and later, follow these instructions to turn off GitLab Duo for the instance.
In GitLab 17.7 and later, follow these instructions to turn off GitLab Duo for the instance.
Prerequisites:
- You must be an administrator.
To turn off GitLab Duo for an instance:
1. On the left sidebar, at the bottom, select **Admin area**.
1. On the left sidebar, select **GitLab Duo**.
1. Select **Change configuration**.
1. Choose an option:
- To turn off GitLab Duo for the instance, but let groups and projects turn it on, select **Off by default**.
- To turn off GitLab Duo for the instance, and to prevent groups or projects from ever turning it on, select **Always off**.
1. Select **Save changes**.
:::TabTitle In 17.4 to 17.6
In GitLab 17.4 to 17.6, follow these instructions to turn off GitLab Duo for the instance.
Prerequisites:

View File

@ -88,7 +88,7 @@ DETAILS:
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/443557) for direct transfer in GitLab 17.4 [with flags](../../../administration/feature_flags.md) named `importer_user_mapping` and `bulk_import_importer_user_mapping`. Disabled by default.
> - Introduced in GitLab 17.6 [for Gitea](https://gitlab.com/gitlab-org/gitlab/-/issues/467084) [with flags](../../../administration/feature_flags.md) named `importer_user_mapping` and `gitea_user_mapping`, and [for GitHub](https://gitlab.com/gitlab-org/gitlab/-/issues/466355) with flags named `importer_user_mapping` and `github_user_mapping`. Disabled by default.
> - Introduced in GitLab 17.7 [for Bitbucket Server](https://gitlab.com/gitlab-org/gitlab/-/issues/466356) [with flags](../../../administration/feature_flags.md) named `importer_user_mapping` and `bitbucket_server_user_mapping`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/472735) for direct transfer in GitLab 17.7.
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/472735) for direct transfer in GitLab 17.7.
FLAG:
The availability of this feature is controlled by feature flags.

View File

@ -17,7 +17,7 @@ You can integrate with external applications to add functionality to GitLab.
You can view and manage integrations for the:
- [Instance](../../../administration/settings/project_integration_management.md#manage-instance-level-default-settings-for-a-project-integration) (self-managed GitLab)
- [Instance](../../../administration/settings/project_integration_management.md#configure-default-settings-for-an-integration) (self-managed GitLab)
- [Group](#manage-group-default-settings-for-a-project-integration)
You can use:
@ -59,7 +59,7 @@ When you make further changes to the group defaults:
- Subgroups and projects with custom settings selected for the integration are not immediately affected and
may choose to use the latest defaults at any time.
If [instance settings](../../../administration/settings/project_integration_management.md#manage-instance-level-default-settings-for-a-project-integration)
If [instance settings](../../../administration/settings/project_integration_management.md#configure-default-settings-for-an-integration)
have also been configured for the same integration, projects in the group inherit settings from the group.
Only the entire settings for an integration can be inherited. Per-field inheritance
@ -114,6 +114,10 @@ To use custom settings for a project or group integration:
## Available integrations
The following integrations can be available on a GitLab instance.
If an instance administrator has configured an [integration allowlist](../../../administration/settings/project_integration_management.md#integration-allowlist),
only those integrations are available.
| Integration | Description | Integration hooks |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------- |
| [Apple App Store Connect](apple_app_store.md) | Use GitLab to build and release an app in the Apple App Store. | **{dotted-circle}** No |

View File

@ -122,7 +122,31 @@ To generate quality code, write clear, descriptive, specific tasks.
For use cases and best practices, follow the [GitLab Duo examples documentation](../../../gitlab_duo_examples.md).
## Open tabs as context
## Advanced Context
When using Code Suggestions, the Advanced Context feature searches in the background
to find and include relevant context from across a user's repository.
For more information, see the [Advanced Context Resolver project epic](https://gitlab.com/groups/gitlab-org/editor-extensions/-/epics/57).
### Advanced Context supported languages
The Advanced Context feature supports these languages:
- Code completion: all configured languages.
- Code generation:
- Go
- Java
- JavaScript
- Kotlin
- Python
- Ruby
- Rust
- TypeScript (`.ts` and `.tsx` files)
- Vue
- YAML
### Open tabs as context
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/464767) in GitLab 17.1 [with a flag](../../../../administration/feature_flags.md) named `advanced_context_resolver`. Disabled by default.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/462750) in GitLab 17.1 [with a flag](../../../../administration/feature_flags.md) named `code_suggestions_context`. Disabled by default.
@ -140,7 +164,9 @@ To get more accurate and relevant results from Code Suggestions and code generat
the contents of the files open in tabs in your IDE. Similar to prompt engineering, these files
give GitLab Duo more information about the standards and practices in your code project.
### Enable open tabs as context
Open tabs as context is part of the Advanced Context feature.
#### Enable open tabs as context
By default, Code Suggestions uses the open files in your IDE for context when making suggestions.
@ -177,7 +203,7 @@ To confirm that open tabs are used as context:
::EndTabs
### Use open tabs as context
#### Use open tabs as context
Open the files you want to provide for context:
@ -201,17 +227,7 @@ To learn about the code that builds the prompt, see these files:
[`ai_gateway/code_suggestions/processing/completions.py`](https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/-/blob/fcb3f485a8f047a86a8166aad81f93b6d82106a7/ai_gateway/code_suggestions/processing/completions.py#L273)
in the `modelops` repository.
Provide feedback about this feature in
[issue 258](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/-/issues/258).
## Advanced Context supported languages
The Advanced Context feature supports these languages:
- Code completion: all configured languages.
- Code generation: Go, Java, JavaScript, Kotlin, Python, Ruby, Rust, TypeScript (`.ts` and `.tsx` files), Vue, and YAML.
## Inference window context
### Inference window context
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/435271) in GitLab 16.8.
> - [Introduced](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/-/issues/206) open tabs context in GitLab 17.2 [with flags](../../../../administration/feature_flags.md) named `advanced_context_resolver` and `code_suggestions_context`. Disabled by default.

View File

@ -15,6 +15,7 @@ module API
if actor.user
load_balancer_stick_request(::User, :user, actor.user.id)
set_current_organization(user: actor.user)
link_scoped_user(params)
end
Gitlab::ApplicationContext.push(
@ -39,6 +40,35 @@ module API
container.lfs_http_url_to_repo
end
def gitaly_context(params)
return unless params[:gitaly_client_context_bin].present?
raw_context = Base64.decode64(params[:gitaly_client_context_bin])
context = Gitlab::Json.parse(raw_context)
raise bad_request!('gitaly_client_context_bin is not a Hash') unless context.is_a?(Hash)
context
rescue JSON::ParserError => e
Gitlab::ErrorTracking.log_exception(e, gitaly_context: params[:gitaly_client_context_bin])
bad_request!('malformed gitaly_client_context_bin')
end
def link_scoped_user(params)
context = gitaly_context(params)
return unless context
scoped_user_id = context['scoped-user-id']
return unless scoped_user_id.present?
scoped_user_id = scoped_user_id.to_i
identity = ::Gitlab::Auth::Identity.link_from_scoped_user_id(actor.user, scoped_user_id)
not_found!("User ID #{scoped_user_id} not found") unless identity
end
# rubocop: disable Metrics/AbcSize
def check_allowed(params)
# This is a separate method so that EE can alter its behaviour more

View File

@ -33,6 +33,16 @@ module Gitlab
end
end
def self.link_from_scoped_user_id(user, scoped_user_id)
scoped_user = ::User.find_by_id(scoped_user_id)
return unless scoped_user
::Gitlab::Auth::Identity.fabricate(user).tap do |identity|
identity.link!(scoped_user) if identity&.composite?
end
end
def self.sidekiq_restore!(job)
ids = Array(job[COMPOSITE_IDENTITY_SIDEKIQ_ARG])

View File

@ -8,7 +8,7 @@ module Gitlab
# The maximum number of patterned glob comparisons that will be
# performed before the rule assumes that it has a match
MAX_PATTERN_COMPARISONS = 10_000
MAX_PATTERN_COMPARISONS = 50_000
WILDCARD_NESTED_PATTERN = "**/*"
@ -85,7 +85,19 @@ module Gitlab
end
def pattern_matches?(paths, pattern_globs, context)
return true if (paths.size * pattern_globs.size) > MAX_PATTERN_COMPARISONS
comparisons = paths.size * pattern_globs.size
if comparisons > MAX_PATTERN_COMPARISONS
Gitlab::AppJsonLogger.info(
class: self.class.name,
message: 'rules:exists pattern comparisons limit exceeded',
project_id: context.project&.id,
paths_size: paths.size,
globs_size: pattern_globs.size,
comparisons: comparisons
)
return true
end
pattern_globs.any? do |glob|
Gitlab::SafeRequestStore.fetch("ci_rules_exists_pattern_matches_#{context.project&.id}_#{glob}") do

View File

@ -328,9 +328,13 @@ module Gitlab
'authorization' => "Bearer #{authorization_token(storage)}",
'client_name' => CLIENT_NAME
}
gitaly_context = {}
relative_path = fetch_relative_path
::Gitlab::Auth::Identity.currently_linked do |identity|
gitaly_context['scoped-user-id'] = identity.scoped_user.id.to_s
end
context_data = Gitlab::ApplicationContext.current
feature_stack = Thread.current[:gitaly_feature_stack]
@ -343,6 +347,8 @@ module Gitlab
metadata['user_id'] = context_data['meta.user_id'].to_s if context_data&.fetch('meta.user_id', nil)
metadata['remote_ip'] = context_data['meta.remote_ip'] if context_data&.fetch('meta.remote_ip', nil)
metadata['relative-path-bin'] = relative_path if relative_path
metadata['gitaly-client-context-bin'] = gitaly_context.to_json if gitaly_context.present?
metadata.merge!(Feature::Gitaly.server_feature_flags(**feature_flag_actors))
metadata.merge!(route_to_primary)

View File

@ -10,7 +10,7 @@ module Gitlab
def initialize(project, options = nil)
@project = project
@project_namespace, _, @project_path = project.full_path.partition('/')
@project_namespace, _, @project_path = project.full_path.downcase.partition('/')
@options = options || {}
end
@ -34,7 +34,7 @@ module Gitlab
# See https://docs.gitlab.com/ee/user/project/pages/getting_started_part_one.html#user-and-group-website-examples.
def is_namespace_homepage? # rubocop:disable Naming/PredicateName -- namespace_homepage is not an
# adjective, so adding "is_" improves understandability
project_path.downcase == "#{project_namespace}.#{instance_pages_domain}"
project_path == "#{project_namespace}.#{instance_pages_domain}"
end
def artifact_url(artifact, job)

View File

@ -5216,6 +5216,9 @@ msgstr ""
msgid "AiPowered|Features are not available. However, any group, subgroup, or project can turn them on."
msgstr ""
msgid "AiPowered|GitLab Duo settings have moved"
msgstr ""
msgid "AiPowered|Help make the next releases of GitLab better. As a member of the GitLab Early Access Program, you can take part in shaping GitLab by letting us know what you think of GitLab Duo experimental and beta features. How does this program work?"
msgstr ""
@ -5258,12 +5261,18 @@ msgstr ""
msgid "AiPowered|Start date: %{startDate}"
msgstr ""
msgid "AiPowered|To make it easier to configure GitLab Duo, the settings have moved to a more visible location. To access them, go to "
msgstr ""
msgid "AiPowered|Turn on experiment and beta GitLab Duo features"
msgstr ""
msgid "AiPowered|Turn on self-hosted models"
msgstr ""
msgid "AiPowered|View GitLab Duo settings"
msgstr ""
msgid "AiPowered|When GitLab Duo is not available, experiment and beta features cannot be turned on."
msgstr ""
@ -26715,6 +26724,9 @@ msgstr ""
msgid "GroupSAML|Are you sure you want to reset the SCIM token? SCIM provisioning will stop working until the new token is updated."
msgstr ""
msgid "GroupSAML|Assign GitLab Duo seats to users in this group"
msgstr ""
msgid "GroupSAML|Before disabling password authentication, enable SAML authentication."
msgstr ""
@ -26844,6 +26856,9 @@ msgstr ""
msgid "GroupSAML|Use SAML group links to manage group membership using SAML."
msgstr ""
msgid "GroupSAML|Users in this SAML group will be assigned a GitLab Duo Pro or GitLab Duo Enterprise seat, if available"
msgstr ""
msgid "GroupSAML|Valid SAML Response"
msgstr ""
@ -26856,6 +26871,9 @@ msgstr ""
msgid "GroupSAML|recommend persistent ID instead of email"
msgstr ""
msgid "GroupSAML|with GitLab Duo seat assignment"
msgstr ""
msgid "GroupSaml|Copy SCIM API endpoint URL"
msgstr ""
@ -35467,6 +35485,9 @@ msgstr ""
msgid "ModelRegistry|Author"
msgstr ""
msgid "ModelRegistry|CI Job"
msgstr ""
msgid "ModelRegistry|Created"
msgstr ""
@ -35476,6 +35497,9 @@ msgstr ""
msgid "ModelRegistry|Latest version"
msgstr ""
msgid "ModelRegistry|MLflow Run ID"
msgstr ""
msgid "ModelRegistry|Model name"
msgstr ""
@ -35488,6 +35512,9 @@ msgstr ""
msgid "ModelRegistry|New version"
msgstr ""
msgid "ModelRegistry|Status"
msgstr ""
msgid "ModelRegistry|Version"
msgstr ""

View File

@ -70,12 +70,12 @@
"@gitlab/application-sdk-browser": "^0.3.3",
"@gitlab/at.js": "1.5.7",
"@gitlab/cluster-client": "^2.4.0",
"@gitlab/duo-ui": "^5.0.0",
"@gitlab/duo-ui": "^3.0.0",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language-rust": "0.1.2",
"@gitlab/svgs": "3.121.0",
"@gitlab/ui": "105.1.1",
"@gitlab/ui": "104.2.0",
"@gitlab/vue-router-vue3": "npm:vue-router@4.1.6",
"@gitlab/vuex-vue3": "npm:vuex@4.0.0",
"@gitlab/web-ide": "^0.0.1-dev-20241112063543",

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
module QA
module Scenario
module Test
module Instance
class NonBlocking < All
tags :"~blocking", :"~smoke", *Specs::Runner::DEFAULT_SKIPPED_TAGS.map { |tag| :"~#{tag}" }
end
end
end
end
end

View File

@ -231,7 +231,6 @@ module QA
file_path: file_path,
status: status(example),
smoke: example.metadata.key?(:smoke).to_s,
blocking: example.metadata.key?(:blocking).to_s,
quarantined: quarantined(example),
job_name: job_name,
merge_request: merge_request,

View File

@ -6,7 +6,7 @@ module QA
module Tools
class MigrateInfluxDataToGcsCsv < MigrateInfluxDataToGcs
TEST_STATS_FIELDS = %w[id testcase file_path name product_group stage job_id job_name
job_url pipeline_id pipeline_url merge_request merge_request_iid smoke blocking quarantined
job_url pipeline_id pipeline_url merge_request merge_request_iid smoke quarantined
retried retry_attempts run_time run_type status ui_fabrication api_fabrication total_fabrication].freeze
FABRICATION_STATS_FIELDS = %w[timestamp resource fabrication_method http_method run_type
merge_request fabrication_time info job_url].freeze

View File

@ -135,7 +135,7 @@ module QA
# @param [String] values record's values to get the data from
# @return [Hash]
def tags(values)
tags = values.slice('name', 'file_path', 'status', 'smoke', 'blocking',
tags = values.slice('name', 'file_path', 'status', 'smoke',
'quarantined', 'job_name', 'merge_request', 'run_type', 'stage',
'product_group', 'testcase', 'exception_class')

View File

@ -1,649 +0,0 @@
# frozen_string_literal: true
require "influxdb-client"
require "terminal-table"
require "slack-notifier"
require 'rainbow/refinement'
module QA
module Tools
class ReliableReport
using Rainbow
include Support::InfluxdbTools
include Support::API
RELIABLE_REPORT_LABEL = "reliable test report"
PROMOTION_BATCH_LIMIT = 10
ALLOWED_EXCEPTION_PATTERNS = [
/Couldn't find option named/,
/Waiting for [\w:]+ to be removed/,
/503 Server Unavailable/,
/\w+ did not appear on [\w:]+ as expected/,
/Internal Server Error/,
/Ambiguous match/,
/500 Error - GitLab/,
/Page did not fully load/,
/Timed out reading data from server/,
/Internal API error/,
/Something went wrong/
].freeze
# Project for report creation: https://gitlab.com/gitlab-org/gitlab
PROJECT_ID = 278964
BLOB_MASTER = 'https://gitlab.com/gitlab-org/gitlab/-/blob/master'
FEATURES_DIR = '/qa/qa/specs/features'
# @param [Integer] range amount of days for results range
def initialize(range)
@range = range.to_i
@slack_channel = "#quality-reports"
end
# Run reliable reporter
#
# @param [Integer] range amount of days for results range
# @param [String] report_in_issue_and_slack
# @return [void]
def self.run(range: 14, report_in_issue_and_slack: "false")
reporter = new(range)
reporter.print_report
if report_in_issue_and_slack == "true"
reporter.report_in_issue_and_slack
reporter.write_specs_json
reporter.close_previous_reports
end
rescue StandardError => e
reporter&.notify_failure(e)
raise(e)
end
# Print top stable specs
#
# @return [void]
def print_report
puts "#{summary_table(stable: true)}\n\n"
puts "Total amount: #{stable_test_runs.sum { |_k, v| v.count }}\n\n"
print_results(stable_results_tables)
return puts("No unstable blocking tests present!".yellow) if unstable_blocking_test_runs.empty?
puts "#{summary_table(stable: false)}\n\n"
puts "Total amount: #{unstable_blocking_test_runs.sum { |_k, v| v.count }}\n\n"
print_results(unstable_blocking_results_tables)
end
# Create report issue
#
# @return [void]
def report_in_issue_and_slack
puts "Creating report".green
issue = api_update(
:post,
"projects/#{PROJECT_ID}/issues",
title: "Reliable e2e test report",
description: report_issue_body,
labels: "#{RELIABLE_REPORT_LABEL},Quality,test,type::maintenance,automation:ml"
)
@report_iid = issue[:iid]
@report_web_url = issue[:web_url]
puts "Created report issue: #{@report_web_url}"
puts "Sending slack notification".green
notifier.post(
icon_emoji: ":tanuki-protect:",
text: <<~TEXT
```#{summary_table(stable: true)}```
```#{summary_table(stable: false)}```
#{@report_web_url}
TEXT
)
puts "Done!"
end
# Close previous reliable test reports
#
# @return [void]
def close_previous_reports
puts "Closing previous reports".green
issues = api_get("projects/#{PROJECT_ID}/issues?labels=#{RELIABLE_REPORT_LABEL}&state=opened")
issues
.reject { |issue| issue[:iid] == report_iid }
.each do |issue|
issue_iid = issue[:iid]
issue_endpoint = "projects/#{PROJECT_ID}/issues/#{issue_iid}"
puts "Closing previous report '#{issue[:web_url]}'"
api_update(:put, issue_endpoint, state_event: "close")
api_update(:post, "#{issue_endpoint}/notes", body: "Closed issue in favor of ##{report_iid}")
end
end
# Notify failure
#
# @param [StandardError] error
# @return [void]
def notify_failure(error)
notifier.post(
text: "Reliable reporter failed to create report. Error: ```#{error}```",
icon_emoji: ":sadpanda:"
)
end
def write_specs_json
# 'unstable_specs.json' contain unstable specs tagged blocking
# 'stable_specs.json' contain stable specs not tagged blocking
File.write('tmp/unstable_specs.json', JSON.pretty_generate(specs_attributes(blocking: true)))
File.write('tmp/stable_specs.json', JSON.pretty_generate(specs_attributes(blocking: false)))
end
private
attr_reader :range, :slack_channel, :report_iid, :report_web_url
# Slack notifier
#
# @return [Slack::Notifier]
def notifier
@notifier ||= Slack::Notifier.new(
slack_webhook_url,
channel: slack_channel,
username: "Reliable Report"
)
end
# Gitlab access token
#
# @return [String]
def gitlab_access_token
@gitlab_access_token ||= ENV["GITLAB_ACCESS_TOKEN"] || raise("Missing GITLAB_ACCESS_TOKEN env variable")
end
# Gitlab api url
#
# @return [String]
def gitlab_api_url
@gitlab_api_url ||= ENV["CI_API_V4_URL"] || raise("Missing CI_API_V4_URL env variable")
end
# Slack webhook url
#
# @return [String]
def slack_webhook_url
@slack_webhook_url ||= ENV["SLACK_WEBHOOK"] || raise("Missing SLACK_WEBHOOK env variable")
end
# Markdown formatted report issue body
#
# @return [String]
def report_issue_body
execution_interval = "(#{Date.today - range} - #{Date.today})"
issue = []
issue << "[[_TOC_]]"
issue << "# Candidates for promotion to blocking #{execution_interval}"
issue << "**Note: MRs will be auto-created for promoting the top #{PROMOTION_BATCH_LIMIT} " \
"specs sorted by most number of successful runs**"
issue << "Total amount: **#{test_count(stable_test_runs)}**"
issue << summary_table(markdown: true, stable: true).to_s
issue << results_markdown(:stable)
return issue.join("\n\n") if unstable_blocking_test_runs.empty?
issue << "# Blocking specs with failures #{execution_interval}"
issue << "**Note:**"
issue << "* Only failures from the nightly, e2e-test-on-omnibus and e2e-test-on-gdk pipelines are considered"
issue << "* Only specs that have a failure rate of equal or greater than 1 percent are considered"
issue << "* Quarantine MRs will be created for all specs listed below"
issue << "Total amount: **#{test_count(unstable_blocking_test_runs)}**"
issue << summary_table(markdown: true, stable: false).to_s
issue << results_markdown(:unstable)
issue.join("\n\n")
end
# Spec summary table
#
# @param [Boolean] markdown
# @param [Boolean] stable
# @return [Terminal::Table]
def summary_table(markdown: false, stable: true)
test_runs = stable ? stable_test_runs : unstable_blocking_test_runs
terminal_table(
rows: test_runs.map do |stage, stage_specs|
[stage, stage_specs.sum { |_k, group_specs| group_specs.length }]
end,
title: "#{stable ? 'Stable' : 'Unstable'} spec summary for past #{range} days".ljust(50),
headings: %w[STAGE COUNT],
markdown: markdown
)
end
# Result tables for stable specs
#
# @param [Boolean] markdown
# @return [Hash]
def stable_results_tables(markdown: false)
results_tables(:stable, markdown: markdown)
end
# Result table for unstable specs
#
# @param [Boolean] markdown
# @return [Hash]
def unstable_blocking_results_tables(markdown: false)
results_tables(:unstable, markdown: markdown)
end
# Markdown formatted tables
#
# @param [Symbol] type result type - :stable, :unstable
# @return [String]
def results_markdown(type)
runs = type == :stable ? stable_test_runs : unstable_blocking_test_runs
results_tables(type, markdown: true).map do |stage, group_tables|
markdown = "## #{stage.capitalize} (#{runs[stage].sum { |_k, group_runs| group_runs.count }})\n\n"
markdown << group_tables.map { |product_group, table| group_results_markdown(product_group, table) }.join
end.join("\n\n")
end
# Markdown formatted group results table
#
# @param [String] product_group
# @param [Terminal::Table] table
# @return [String]
def group_results_markdown(product_group, table)
<<~MARKDOWN.chomp
<details>
<summary>Executions table ~"group::#{product_group.tr('_', ' ')}" (#{table.rows.size})</summary>
#{table}
</details>
MARKDOWN
end
# Results table
#
# @param [Symbol] type result type - :stable, :unstable
# @param [Boolean] markdown
# @return [Hash<String, Hash<String, Terminal::Table>>] grouped by stage and product_group
def results_tables(type, markdown: false)
(type == :stable ? stable_test_runs : unstable_blocking_test_runs).to_h do |stage, specs|
headings = ['NAME', 'RUNS', 'FAILURES', 'FAILURE RATE'].freeze
[stage, specs.transform_values do |group_specs|
terminal_table(
title: "Top #{type} specs in '#{stage}::#{specs.key(group_specs)}' group for past #{range} days",
headings: headings,
markdown: markdown,
rows: group_specs.map do |name, result|
[
name_column(name: name, file: result[:file], link: result[:link],
exceptions_and_related_urls: result[:exceptions_and_related_urls], markdown: markdown),
*table_params(result.values)
]
end
)
end]
end
end
# Stable specs
#
# @return [Hash]
def stable_test_runs
@top_stable ||= begin
stable_specs = test_runs(blocking: false).each do |stage, stage_specs|
stage_specs.transform_values! do |group_specs|
group_specs.reject { |k, v| v[:failure_rate] != 0 }
.sort_by { |k, v| -v[:runs] }
.to_h
end
end
stable_specs.transform_values { |v| v.reject { |_, v| v.empty? } }.reject { |_, v| v.empty? }
end
end
# Unstable blocking specs
#
# @return [Hash]
def unstable_blocking_test_runs
@top_unstable_blocking ||= begin
unstable = test_runs(blocking: true).each do |_stage, stage_specs|
stage_specs.transform_values! do |group_specs|
group_specs.reject { |_, v| v[:failure_rate] == 0 }
.sort_by { |_, v| -v[:failure_rate] }
.to_h
end
end
unstable.transform_values { |v| v.reject { |_, v| v.empty? } }.reject { |_, v| v.empty? }
end
end
def print_results(results)
results.each do |_stage, stage_results|
stage_results.each_value { |group_results_table| puts "#{group_results_table}\n\n" }
end
end
def test_count(test_runs)
test_runs.sum do |_stage, stage_results|
stage_results.sum { |_product_group, group_results| group_results.count }
end
end
# Terminal table for result formatting
#
# @param [Array] rows
# @param [Array] headings
# @param [String] title
# @param [Boolean] markdown
# @return [Terminal::Table]
def terminal_table(rows:, headings:, title:, markdown:)
Terminal::Table.new(
headings: headings,
title: markdown ? nil : title,
rows: rows,
style: markdown ? { border: :markdown } : { all_separators: true }
)
end
# Spec parameters for table row
#
# @param [Array] parameters
# @return [Array]
def table_params(parameters)
[*parameters[2..3], "#{parameters.last}%"]
end
# Name column content
#
# @param [String] name
# @param [String] file
# @param [String] link
# @param [Hash] exceptions_and_related_urls
# @param [Boolean] markdown
# @return [String]
def name_column(name:, file:, link:, exceptions_and_related_urls:, markdown: false)
if markdown
return "**Name**: #{name}<br>**File**: " \
"[#{file}](#{link})#{exceptions_markdown(exceptions_and_related_urls)}"
end
wrapped_name = name.length > 150 ? "#{name} ".scan(/.{1,150} /).map(&:strip).join("\n") : name
"Name: '#{wrapped_name}'\nFile: #{file.ljust(160)}"
end
# Formatted exceptions with link to job url
#
# @param [Hash] exceptions_and_related_urls
# @return [String]
def exceptions_markdown(exceptions_and_related_urls)
return '' if exceptions_and_related_urls.empty?
"<br>**Exceptions**:#{exceptions_and_related_urls.keys.map do |e|
"<br>- [`#{e.truncate(250).tr('`', "'")}`](#{exceptions_and_related_urls[e]})"
end.join('')}"
end
def api_query_notblocking
@api_query_notblocking ||= begin
log_fetching_query_data(true)
query_api.query(query: query(false))
end
end
def api_query_blocking
@api_query_blocking ||= begin
log_fetching_query_data(true)
query_api.query(query: query(true))
end
end
def log_fetching_query_data(reliable)
puts("Fetching data on #{reliable ? 'reliable ' : ''}test execution for past #{range} days\n".green)
end
def specs_attributes(blocking:)
all_runs = query_for(blocking: blocking)
specs_array = all_runs.each_with_object([]) do |table, arr|
records = table.records.sort_by { |record| record.values["_time"] }
next if within_execution_range(records.first.values["_time"], records.last.values["_time"])
result = spec_attributes_per_run(records)
# When collecting specs not in blocking bucket for promotion, skip specs with failures
next if !blocking && result[:failed] != 0
next if blocking && skip_blocking_spec_record?(failed_count: result[:failed],
failure_issue: result[:failure_issue],
failed_run_type: result[:failed_run_type],
failure_rate: result[:failure_rate])
arr << result
end
specs_array = specs_array.sort_by { |item| item[:runs] }.reverse unless blocking
{
type: blocking ? 'Unstable Specs' : 'Stable Specs',
report_issue: report_web_url,
specs: specs_array
}
end
def skip_blocking_spec_record?(failed_count:, failure_issue:, failed_run_type:, failure_rate:)
# For unstable blocking specs, skip if no failures or
return true if failed_count == 0 ||
# skip if a failure issue does not exist or
failure_issue&.exclude?('issues') ||
# skip if run type is other than nightly and non-MR e2e-test-on-omnibus pipeline or
(failed_run_type & %w[e2e-test-on-omnibus e2e-test-on-gdk nightly]).empty? ||
# skip if failure rate of tests is less than or equal to 1 percent
failure_rate <= 1
false
end
def spec_attributes_per_run(records)
failed_records = records.select do |r|
r.values["status"] == "failed" && !allowed_failure?(r.values["failure_exception"])
end
failure_issue = issue_for_most_failures(failed_records)
last_record = records.last.values
name = last_record["name"]
file = last_record["file_path"].split("/").last
link = BLOB_MASTER + FEATURES_DIR + last_record["file_path"]
file_path = FEATURES_DIR + last_record["file_path"]
stage = last_record["stage"] || "unknown"
testcase = last_record["testcase"]
run_type = records.map { |record| record.values['run_type'] }.uniq
failed_run_type = failed_records.map { |record| record.values['run_type'] }.uniq
product_group = last_record["product_group"] || "unknown"
runs = records.count
failure_rate = (failed_records.count.to_f / runs) * 100
{
stage: stage,
product_group: product_group,
name: name,
file: file,
link: link,
runs: runs,
failed: failed_records.count,
failure_issue: failure_issue || '',
failure_rate: failure_rate == 0 ? failure_rate.round(0) : failure_rate.round(2),
testcase: testcase,
file_path: file_path,
all_run_type: run_type,
failed_run_type: failed_run_type
}
end
def query_for(blocking:)
blocking ? api_query_blocking : api_query_notblocking
end
# rubocop:disable Metrics/AbcSize
# Test executions grouped by name
#
# @param [Boolean] blocking
# @return [Hash<String, Hash>]
def test_runs(blocking:)
all_runs = query_for(blocking: blocking)
all_runs.each_with_object(Hash.new { |hsh, key| hsh[key] = {} }) do |table, result|
records = table.records.sort_by { |record| record.values["_time"] }
next if within_execution_range(records.first.values["_time"], records.last.values["_time"])
last_record = records.last.values
name = last_record["name"]
file = last_record["file_path"].split("/").last
link = BLOB_MASTER + FEATURES_DIR + last_record["file_path"]
stage = last_record["stage"] || "unknown"
product_group = last_record["product_group"] || "unknown"
runs = records.count
failed_records = records.select do |r|
r.values["status"] == "failed" && !allowed_failure?(r.values["failure_exception"])
end
failure_issue = issue_for_most_failures(failed_records)
failed_run_type = failed_records.map { |record| record.values['run_type'] }.uniq
failure_rate = (failed_records.count.to_f / runs) * 100
next if blocking && skip_blocking_spec_record?(failed_count: failed_records.count,
failure_issue: failure_issue, failed_run_type: failed_run_type, failure_rate: failure_rate)
result[stage][product_group] ||= {}
result[stage][product_group][name] = {
file: file,
link: link,
runs: runs,
failed: failed_records.count,
exceptions_and_related_urls: exceptions_and_related_urls(records),
failure_rate: failure_rate == 0 ? failure_rate.round(0) : failure_rate.round(2)
}
end
end
# rubocop:enable Metrics/AbcSize
# Return hash of exceptions as key and failure_issue or job_url urls as value
#
# @param [Array<InfluxDB2::FluxRecord>] records
# @return [Hash]
def exceptions_and_related_urls(records)
records_with_exception = records.reject { |r| !r.values["failure_exception"] }
# Since exception is the key in the below hash, only one instance of an occurrence is kept
records_with_exception.to_h do |r|
[r.values["failure_exception"], r.values["failure_issue"] || r.values["job_url"]]
end
end
# Return the failure that has the most occurrence
#
# @param [Array<InfluxDB2::FluxRecord>] records
# @return [String] the failure with most occurrence
def issue_for_most_failures(records)
return '' if records.empty?
issues = records.filter_map { |r| r.values["failure_issue"] }
return '' if issues.empty?
issues.tally.max_by { |_, count| count }&.first
end
# Check if failure is allowed
#
# @param [String] failure_exception
# @return [Boolean]
def allowed_failure?(failure_exception)
ALLOWED_EXCEPTION_PATTERNS.any? { |pattern| pattern.match?(failure_exception) }
end
# Returns true if first_time is before our range, or if last_time is before report date
# offset 1 day due to how schedulers are configured and first run can be 1 day later
#
# @param [String] first_time
# @param [String] last_time
# @return [Boolean]
def within_execution_range(first_time, last_time)
(Date.today - Date.parse(first_time)).to_i < (range - 1) || (Date.today - Date.parse(last_time)).to_i > 1
end
# Flux query
#
# @param [Boolean] blocking
# @return [String]
def query(blocking)
<<~QUERY
from(bucket: "#{Support::InfluxdbTools::INFLUX_MAIN_TEST_METRICS_BUCKET}")
|> range(start: -#{range}d)
|> filter(fn: (r) => r._measurement == "test-stats")
|> filter(fn: (r) => r.run_type == "staging-full" or
r.run_type == "staging-sanity" or
r.run_type == "production-full" or
r.run_type == "production-sanity" or
r.run_type == "e2e-test-on-omnibus" or
r.run_type == "e2e-test-on-gdk" or
r.run_type == "nightly"
)
|> filter(fn: (r) => r.job_name != "airgapped" and
r.job_name != "nplus1-instance-image"
)
|> filter(fn: (r) => r.status != "pending" and
r.merge_request == "false" and
r.quarantined == "false" and
r.smoke == "false" and
r.blocking == "#{blocking}"
)
|> filter(fn: (r) => r["_field"] == "job_url" or
r["_field"] == "failure_exception" or
r["_field"] == "id" or
r["_field"] == "failure_issue"
)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> group(columns: ["name"])
QUERY
end
# Api get request
#
# @param [String] path
# @param [Hash] payload
# @return [Hash, Array]
def api_get(path)
response = get("#{gitlab_api_url}/#{path}", { headers: { "PRIVATE-TOKEN" => gitlab_access_token } })
parse_body(response)
end
# Api update request
#
# @param [Symbol] verb :post or :put
# @param [String] path
# @param [Hash] payload
# @return [Hash, Array]
def api_update(verb, path, **payload)
response = send(
verb,
"#{gitlab_api_url}/#{path}",
payload,
{ headers: { "PRIVATE-TOKEN" => gitlab_access_token } }
)
parse_body(response)
end
end
end
end

View File

@ -32,7 +32,6 @@ describe QA::Support::Formatters::TestMetricsFormatter do
let(:branch) { 'master' }
let(:run_type) { 'staging-full' }
let(:smoke) { 'false' }
let(:blocking) { 'false' }
let(:quarantined) { 'false' }
let(:failure_issue) { '' }
let(:influx_client) { instance_double('InfluxDB2::Client', create_write_api: influx_write_api) }
@ -66,7 +65,6 @@ describe QA::Support::Formatters::TestMetricsFormatter do
file_path: file_path.gsub('./qa/specs/features', ''),
status: status,
smoke: smoke,
blocking: blocking,
quarantined: quarantined,
job_name: 'test-job',
merge_request: 'false',
@ -195,18 +193,6 @@ describe QA::Support::Formatters::TestMetricsFormatter do
stub_env('QA_INFLUXDB_TIMEOUT', "10")
end
context 'with blocking spec' do
let(:blocking) { 'true' }
it 'exports data with correct blocking tag', :aggregate_failures do
run_spec do
it('spec', :blocking, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234') {}
end
expect(influx_write_api).to have_received(:write).with(data: [data])
end
end
context 'with product group tag' do
let(:expected_data) { data.tap { |d| d[:tags][:product_group] = :import } }
@ -222,7 +208,7 @@ describe QA::Support::Formatters::TestMetricsFormatter do
context 'with smoke spec' do
let(:smoke) { 'true' }
it 'exports data with correct blocking tag', :aggregate_failures do
it 'exports data with correct smoke tag', :aggregate_failures do
run_spec do
it('spec', :smoke, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234') {}
end

View File

@ -1,682 +0,0 @@
# frozen_string_literal: true
describe QA::Tools::ReliableReport do
include QA::Support::Helpers::StubEnv
before do
stub_env("QA_INFLUXDB_URL", "url")
stub_env("QA_INFLUXDB_TOKEN", "token")
stub_env("SLACK_WEBHOOK", "slack_url")
stub_env("CI_API_V4_URL", "gitlab_api_url")
stub_env("GITLAB_ACCESS_TOKEN", "gitlab_token")
allow(RestClient::Request).to receive(:execute)
allow(Slack::Notifier).to receive(:new).and_return(slack_notifier)
allow(InfluxDB2::Client).to receive(:new).and_return(influx_client)
allow(query_api).to receive(:query).with(query: flux_query(blocking: false)).and_return(runs)
allow(query_api).to receive(:query).with(query: flux_query(blocking: true)).and_return(reliable_runs)
end
let(:slack_notifier) { instance_double("Slack::Notifier", post: nil) }
let(:influx_client) { instance_double("InfluxDB2::Client", create_query_api: query_api) }
let(:query_api) { instance_double("InfluxDB2::QueryApi") }
let(:slack_channel) { "#quality-reports" }
let(:range) { 14 }
let(:issue_url) { "https://gitlab.com/issue/1" }
let(:time) { "2021-12-07T04:05:25.000000000+00:00" }
let(:failure_message) { 'random failure message' }
let(:run_values) do
{
"name" => "stable spec1",
"status" => "passed",
"file_path" => "/some/spec.rb",
"stage" => "create",
"product_group" => "code_review",
"testcase" => "https://testcase/url",
"run_type" => "e2e-test-on-omnibus",
"_time" => time
}
end
let(:run_more_values) do
{
"name" => "stable spec2",
"status" => "passed",
"file_path" => "/some/spec.rb",
"stage" => "manage",
"product_group" => "import_and_integrate",
"testcase" => "https://testcase/url",
"run_type" => "e2e-test-on-omnibus",
"_time" => time
}
end
let(:runs) do
[
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: run_values),
instance_double("InfluxDB2::FluxRecord", values: run_values),
instance_double("InfluxDB2::FluxRecord", values: run_values.merge({ "_time" => Time.now.to_s }))
]
),
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: run_more_values),
instance_double("InfluxDB2::FluxRecord", values: run_more_values),
instance_double("InfluxDB2::FluxRecord", values: run_more_values.merge({ "_time" => Time.now.to_s }))
]
)
]
end
let(:reliable_run_values) do
{
"name" => "unstable spec",
"status" => "failed",
"file_path" => "/some/spec.rb",
"stage" => "create",
"product_group" => "code_review",
"failure_exception" => failure_message,
"job_url" => "https://job/url",
"testcase" => "https://testcase/url",
"failure_issue" => "https://issues/url",
"run_type" => "e2e-test-on-omnibus",
"_time" => time
}
end
let(:reliable_run_more_values) do
{
"name" => "unstable spec",
"status" => "failed",
"file_path" => "/some/spec.rb",
"stage" => "manage",
"product_group" => "import_and_integrate",
"failure_exception" => failure_message,
"job_url" => "https://job/url",
"testcase" => "https://testcase/url",
"failure_issue" => "https://issues/url",
"run_type" => "e2e-test-on-omnibus",
"_time" => time
}
end
let(:reliable_runs) do
[
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: { **reliable_run_values, "status" => "passed" }),
instance_double("InfluxDB2::FluxRecord", values: reliable_run_values),
instance_double("InfluxDB2::FluxRecord", values: reliable_run_values.merge({ "_time" => Time.now.to_s }))
]
),
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: { **reliable_run_more_values, "status" => "passed" }),
instance_double("InfluxDB2::FluxRecord", values: reliable_run_more_values),
instance_double("InfluxDB2::FluxRecord", values: reliable_run_more_values.merge({ "_time" => Time.now.to_s }))
]
)
]
end
def flux_query(blocking:)
<<~QUERY
from(bucket: "e2e-test-stats-main")
|> range(start: -#{range}d)
|> filter(fn: (r) => r._measurement == "test-stats")
|> filter(fn: (r) => r.run_type == "staging-full" or
r.run_type == "staging-sanity" or
r.run_type == "production-full" or
r.run_type == "production-sanity" or
r.run_type == "e2e-test-on-omnibus" or
r.run_type == "e2e-test-on-gdk" or
r.run_type == "nightly"
)
|> filter(fn: (r) => r.job_name != "airgapped" and
r.job_name != "nplus1-instance-image"
)
|> filter(fn: (r) => r.status != "pending" and
r.merge_request == "false" and
r.quarantined == "false" and
r.smoke == "false" and
r.blocking == "#{blocking}"
)
|> filter(fn: (r) => r["_field"] == "job_url" or
r["_field"] == "failure_exception" or
r["_field"] == "id" or
r["_field"] == "failure_issue"
)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> group(columns: ["name"])
QUERY
end
def expected_stage_markdown(result, stage, product_group, type)
<<~SECTION.strip
## #{stage.capitalize} (1)
<details>
<summary>Executions table ~\"group::#{product_group}\" (1)</summary>
#{table(result, ['NAME', 'RUNS', 'FAILURES', 'FAILURE RATE'], "Top #{type} specs in '#{stage}' stage for past #{range} days", true)}
</details>
SECTION
end
def expected_summary_table(summary, type, markdown = false)
table(summary, %w[STAGE COUNT], "#{type.capitalize} spec summary for past #{range} days".ljust(50), markdown)
end
def table(rows, headings, title, markdown = false)
Terminal::Table.new(
headings: headings,
title: markdown ? nil : title,
rows: rows,
style: markdown ? { border: :markdown } : { all_separators: true }
)
end
def name_column(spec_name, exceptions_and_related_urls = {})
"**Name**: #{spec_name}<br>**File**: [spec.rb](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb)#{exceptions_markdown(exceptions_and_related_urls)}"
end
def exceptions_markdown(exceptions_and_related_urls)
exceptions_and_related_urls.empty? ? '' : "<br>**Exceptions**:<br>- [`#{failure_message}`](https://issues/url)"
end
describe '.run' do
subject(:run) { described_class.run(range: range, report_in_issue_and_slack: create_issue) }
context "without report creation" do
let(:create_issue) { "false" }
it "does not create report issue", :aggregate_failures do
expect { run }.to output.to_stdout
expect(RestClient::Request).not_to have_received(:execute)
expect(slack_notifier).not_to have_received(:post)
end
end
context "with report creation" do
let(:create_issue) { "true" }
let(:iid) { 2 }
let(:old_iid) { 1 }
let(:issue_endpoint) { "gitlab_api_url/projects/278964/issues" }
let(:common_api_args) do
{
verify_ssl: false,
headers: { "PRIVATE-TOKEN" => "gitlab_token" }
}
end
let(:create_issue_response) do
instance_double(
"RestClient::Response",
code: 200,
body: { web_url: issue_url, iid: iid }.to_json
)
end
let(:open_issues_response) do
instance_double(
"RestClient::Response",
code: 200,
body: [{ web_url: issue_url, iid: iid }, { web_url: issue_url, iid: old_iid }].to_json
)
end
let(:success_response) do
instance_double("RestClient::Response", code: 200, body: {}.to_json)
end
before do
allow(RestClient::Request).to receive(:execute).exactly(4).times.and_return(
create_issue_response,
open_issues_response,
success_response,
success_response
)
end
shared_examples 'report creation' do
it "creates report issue" do
expect { run }.to output.to_stdout
expect(RestClient::Request).to have_received(:execute).with(
method: :post,
url: issue_endpoint,
payload: {
title: "Reliable e2e test report",
description: expected_issue_body,
labels: "reliable test report,Quality,test,type::maintenance,automation:ml"
},
**common_api_args
)
expect(RestClient::Request).to have_received(:execute).with(
method: :get,
url: "#{issue_endpoint}?labels=reliable test report&state=opened",
**common_api_args
)
expect(RestClient::Request).to have_received(:execute).with(
method: :put,
url: "#{issue_endpoint}/#{old_iid}",
payload: {
state_event: "close"
},
**common_api_args
)
expect(RestClient::Request).to have_received(:execute).with(
method: :post,
url: "#{issue_endpoint}/#{old_iid}/notes",
payload: {
body: "Closed issue in favor of ##{iid}"
},
**common_api_args
)
expect(slack_notifier).to have_received(:post).with(
icon_emoji: ":tanuki-protect:",
text: expected_slack_text
)
end
end
context "with disallowed exception" do
let(:failure_message) { 'random failure message' }
let(:expected_issue_body) do
<<~TXT.strip
[[_TOC_]]
# Candidates for promotion to blocking (#{Date.today - range} - #{Date.today})
**Note: MRs will be auto-created for promoting the top #{QA::Tools::ReliableReport::PROMOTION_BATCH_LIMIT} specs sorted by most number of successful runs**
Total amount: **2**
#{expected_summary_table([['create', 1], ['manage', 1]], :stable, true)}
#{expected_stage_markdown([[name_column('stable spec1'), 3, 0, '0%']], 'create', 'code review', :stable)}
#{expected_stage_markdown([[name_column('stable spec2'), 3, 0, '0%']], 'manage', 'import and integrate', :stable)}
# Blocking specs with failures (#{Date.today - range} - #{Date.today})
**Note:**
* Only failures from the nightly, e2e-test-on-omnibus and e2e-test-on-gdk pipelines are considered
* Only specs that have a failure rate of equal or greater than 1 percent are considered
* Quarantine MRs will be created for all specs listed below
Total amount: **2**
#{expected_summary_table([['create', 1], ['manage', 1]], :unstable, true)}
#{expected_stage_markdown([[name_column('unstable spec', { failure_message => 'https://job/url' }), 3, 2, '66.67%']], 'create', 'code review', :unstable)}
#{expected_stage_markdown([[name_column('unstable spec', { failure_message => 'https://job/url' }), 3, 2, '66.67%']], 'manage', 'import and integrate', :unstable)}
TXT
end
let(:expected_slack_text) do
<<~TEXT
```#{expected_summary_table([['create', 1], ['manage', 1]], :stable)}```
```#{expected_summary_table([['create', 1], ['manage', 1]], :unstable)}```
#{issue_url}
TEXT
end
it_behaves_like "report creation"
end
context "with allowed exception" do
let(:failure_message) { 'Ambiguous match' }
let(:expected_issue_body) do
<<~TXT.strip
[[_TOC_]]
# Candidates for promotion to blocking (#{Date.today - range} - #{Date.today})
**Note: MRs will be auto-created for promoting the top #{QA::Tools::ReliableReport::PROMOTION_BATCH_LIMIT} specs sorted by most number of successful runs**
Total amount: **2**
#{expected_summary_table([['create', 1], ['manage', 1]], :stable, true)}
#{expected_stage_markdown([[name_column('stable spec1'), 3, 0, '0%']], 'create', 'code review', :stable)}
#{expected_stage_markdown([[name_column('stable spec2'), 3, 0, '0%']], 'manage', 'import and integrate', :stable)}
TXT
end
let(:expected_slack_text) do
<<~TEXT
```#{expected_summary_table([['create', 1], ['manage', 1]], :stable)}```
```#{expected_summary_table([], :unstable)}```
#{issue_url}
TEXT
end
it_behaves_like "report creation"
end
end
context "with failure" do
let(:create_issue) { "true" }
before do
allow(query_api).to receive(:query).and_raise("Connection error!")
end
it "notifies failure", :aggregate_failures do
expect { expect { run }.to raise_error("Connection error!") }.to output.to_stdout
expect(slack_notifier).to have_received(:post).with(
icon_emoji: ":sadpanda:",
text: "Reliable reporter failed to create report. Error: ```Connection error!```"
)
end
end
end
describe "#allowed_failure?" do
subject(:reliable_report) { described_class.new(14) }
it "returns true for an allowed failure" do
expect(reliable_report.send(:allowed_failure?, "Couldn't find option named abc")).to be true
end
it "returns false for disallowed failure" do
expect(reliable_report.send(:allowed_failure?,
%q([Unable to find css "[data-testid=\"user_action_dropdown\"]"]))).to be false
end
end
describe "#issue_for_most_failures" do
subject(:reliable_report) { described_class.new(14) }
let(:failure_message_1) { "This is a failure exception 1" }
let(:failure_message_2) { "This is a failure exception 2" }
let(:job_url) { "https://example.com/job/url" }
let(:failure_issue_url_1) { "https://example.com/failure/issue_1" }
let(:failure_issue_url_2) { "https://example.com/failure/issue_2" }
let(:records) do
[
instance_double("InfluxDB2::FluxRecord", values: values_1),
instance_double("InfluxDB2::FluxRecord", values: values_2),
instance_double("InfluxDB2::FluxRecord", values: values_2)
]
end
let(:values_1) do
{
"failure_exception" => failure_message_1,
"failure_issue" => failure_issue_url_1,
"job_url" => job_url
}
end
let(:values_2) do
{
"failure_exception" => failure_message_2,
"failure_issue" => failure_issue_url_2,
"job_url" => job_url
}
end
let(:values_3) do
{
"failure_exception" => failure_message_2,
"failure_issue" => failure_issue_url_2,
"job_url" => job_url
}
end
it 'returns the failure issue with most failures' do
expect(reliable_report.send(:issue_for_most_failures, records)).to eq(failure_issue_url_2)
end
end
describe "#exceptions_and_related_urls" do
subject(:reliable_report) { described_class.new(14) }
let(:failure_message) { "This is a failure exception" }
let(:job_url) { "https://example.com/job/url" }
let(:failure_issue_url) { "https://example.com/failure/issue" }
let(:records) do
[instance_double("InfluxDB2::FluxRecord", values: values)]
end
context "without failure_exception" do
let(:values) do
{
"failure_exception" => nil,
"job_url" => job_url,
"failure_issue" => failure_issue_url
}
end
it "returns an empty hash" do
expect(reliable_report.send(:exceptions_and_related_urls, records)).to be_empty
end
end
context "with failure_exception" do
context "without failure_issue" do
let(:values) do
{
"failure_exception" => failure_message,
"job_url" => job_url
}
end
it "returns job_url as value" do
expect(reliable_report.send(:exceptions_and_related_urls, records).values).to eq([job_url])
end
end
context "with failure_issue and job_url" do
let(:values) do
{
"failure_exception" => failure_message,
"failure_issue" => failure_issue_url,
"job_url" => job_url
}
end
it "returns failure_issue as value" do
expect(reliable_report.send(:exceptions_and_related_urls, records).values).to eq([failure_issue_url])
end
end
end
end
describe "#specs_attributes" do
subject(:reliable_report) { described_class.new(14) }
let(:promotion_batch_limit) { 10 }
let(:report_web_url) { 'https://report/url' }
before do
allow(reliable_report).to receive(:report_web_url).and_return(report_web_url)
end
shared_examples "spec attributes" do |blocking|
it "returns #{blocking} spec attributes" do
stub_const("QA::Tools::ReliableReport::PROMOTION_BATCH_LIMIT", promotion_batch_limit)
expect(reliable_report.send(:specs_attributes, blocking: blocking)).to eq(expected_specs_attributes)
end
end
context "with blocking true" do
let(:expected_specs_attributes) do
{ type: "Unstable Specs",
report_issue: "https://report/url",
specs:
[
{ stage: "create",
product_group: "code_review",
name: "unstable spec",
file: "spec.rb",
link: "https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb",
runs: 3,
failed: 2,
failure_issue: "https://issues/url",
failure_rate: 66.67,
testcase: "https://testcase/url",
file_path: "/qa/qa/specs/features/some/spec.rb",
all_run_type: ["e2e-test-on-omnibus"],
failed_run_type: ["e2e-test-on-omnibus"] },
{ stage: "manage",
product_group: "import_and_integrate",
name: "unstable spec",
file: "spec.rb",
link: "https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb",
runs: 3,
failed: 2,
failure_issue: "https://issues/url",
failure_rate: 66.67,
testcase: "https://testcase/url",
file_path: "/qa/qa/specs/features/some/spec.rb",
all_run_type: ["e2e-test-on-omnibus"],
failed_run_type: ["e2e-test-on-omnibus"] }
] }
end
it_behaves_like "spec attributes", true
end
context "with blocking false" do
let(:expected_specs_attributes) do
{
type: "Stable Specs",
report_issue: "https://report/url",
specs:
[
{ stage: "manage",
product_group: "import_and_integrate",
name: "stable spec2",
file: "spec.rb",
link: "https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb",
runs: 3,
failed: 0,
failure_issue: "",
failure_rate: 0,
testcase: "https://testcase/url",
file_path: "/qa/qa/specs/features/some/spec.rb",
all_run_type: ["e2e-test-on-omnibus"],
failed_run_type: [] },
{ stage: "create",
product_group: "code_review",
name: "stable spec1",
file: "spec.rb",
link: "https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb",
runs: 3,
failed: 0,
failure_issue: "",
failure_rate: 0,
testcase: "https://testcase/url",
file_path: "/qa/qa/specs/features/some/spec.rb",
all_run_type: ["e2e-test-on-omnibus"],
failed_run_type: [] }
]
}
end
it_behaves_like "spec attributes", false
context "with specific PROMOTION_BATCH_LIMIT" do
let(:promotion_batch_limit) { 1 }
let(:runs) do
[
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: run_values),
instance_double("InfluxDB2::FluxRecord", values: run_values),
instance_double("InfluxDB2::FluxRecord", values: run_values.merge({ "_time" => Time.now.to_s }))
]
),
instance_double(
"InfluxDB2::FluxTable",
records: [
instance_double("InfluxDB2::FluxRecord", values: run_more_values),
instance_double("InfluxDB2::FluxRecord", values: run_more_values)
]
)
]
end
let(:expected_specs_attributes) do
{
type: "Stable Specs",
report_issue: "https://report/url",
specs:
[
{ stage: "create",
product_group: "code_review",
name: "stable spec1",
file: "spec.rb",
link: "https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/specs/features/some/spec.rb",
runs: 3,
failed: 0,
failure_issue: "",
failure_rate: 0,
testcase: "https://testcase/url",
file_path: "/qa/qa/specs/features/some/spec.rb",
all_run_type: ["e2e-test-on-omnibus"],
failed_run_type: [] }
]
}
end
it_behaves_like "spec attributes", false
end
end
end
describe "#skip_blocking_spec_record?" do
subject(:reliable_report) { described_class.new(14) }
using RSpec::Parameterized::TableSyntax
where(:failed_count, :failure_issue, :failed_run_type, :failure_rate, :result) do
1 | 'https://failure/issues/url' | ['e2e-test-on-omnibus'] | 2 | false
1 | 'https://failure/issues/url' | ['e2e-test-on-gdk'] | 2 | false
1 | 'https://failure/issues/url' | ['nightly'] | 2 | false
0 | 'https://failure/issues/url' | ['e2e-test-on-gdk'] | 2 | true
1 | 'https://failure/issue/url' | ['e2e-test-on-gdk'] | 2 | true
1 | 'https://failure/issues/url' | ['abc'] | 2 | true
1 | 'https://failure/issues/url' | ['e2e-test-on-gdk'] | 0 | true
end
with_them do
it do
expect(reliable_report.send(:skip_blocking_spec_record?, failed_count: failed_count,
failure_issue: failure_issue, failed_run_type: failed_run_type, failure_rate: failure_rate))
.to eq result
end
end
end
end

View File

@ -1,6 +0,0 @@
# frozen_string_literal: true
desc "Fetch reliable and unreliable spec data and create report"
task :reliable_spec_report, [:range, :report_in_issue_and_slack] do |_task, args|
QA::Tools::ReliableReport.run(**args)
end

View File

@ -1,176 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Work item linked items', :js, feature_category: :team_planning do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:user) { create(:user) }
let_it_be(:work_item) { create(:work_item, project: project) }
let(:work_items_path) { project_work_item_path(project, work_item.iid) }
let_it_be(:task) { create(:work_item, :task, project: project, title: 'Task 1') }
let_it_be(:milestone) { create(:milestone, project: project, title: '1.0') }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:objective) do
create(:work_item, :objective, project: project, milestone: milestone,
title: 'Objective 1', labels: [label])
end
context 'for signed in user' do
let(:token_input_selector) { '[data-testid="work-item-token-select-input"] .gl-token-selector-input' }
before_all do
project.add_developer(user)
end
before do
sign_in(user)
stub_feature_flags(work_items: true)
visit work_items_path
wait_for_requests
end
it 'are not displayed when issue does not have work item links', :aggregate_failures do
within_testid('work-item-relationships') do
expect(page).to have_selector('[data-testid="link-item-add-button"]')
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
expect(page).not_to have_selector('[data-testid="work-item-linked-items-list"]')
end
end
it 'toggles widget body', :aggregate_failures do
within_testid('work-item-relationships') do
expect(page).to have_selector('[data-testid="crud-empty"]')
click_button 'Collapse'
expect(page).not_to have_selector('[data-testid="crud-empty"]')
click_button 'Expand'
expect(page).to have_selector('[data-testid="crud-empty"]')
end
end
it 'toggles form', :aggregate_failures do
within_testid('work-item-relationships') do
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
click_button 'Add'
expect(page).to have_selector('[data-testid="link-work-item-form"]')
click_button 'Cancel'
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
end
end
it 'links a new item with work item text', :aggregate_failures,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/444980' do
verify_linked_item_added(task.title)
end
it 'links a new item with work item iid', :aggregate_failures,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/444751' do
verify_linked_item_added(task.iid)
end
it 'links a new item with work item wildcard iid', :aggregate_failures,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/444914' do
verify_linked_item_added("##{task.iid}")
end
it 'links a new item with work item reference', :aggregate_failures,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/445635' do
verify_linked_item_added(task.to_reference(full: true))
end
it 'links a new item with work item url', :aggregate_failures,
quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/438014' do
verify_linked_item_added("#{Gitlab.config.gitlab.url}/#{task.project.full_path}/-/work_items/#{task.iid}")
end
it 'removes a linked item', :aggregate_failures do
within_testid('work-item-relationships') do
click_button 'Add'
within_testid('link-work-item-form') do
expect(page).to have_button('Add', disabled: true)
find_by_testid('work-item-token-select-input').set(task.title)
wait_for_all_requests
click_button task.title
expect(page).to have_button('Add', disabled: false)
click_button 'Add'
wait_for_all_requests
end
expect(find('.work-items-list')).to have_content('Task 1')
find_by_testid('links-child').hover
find_by_testid('remove-work-item-link').click
wait_for_all_requests
expect(page).not_to have_content('Task 1')
end
end
it 'passes axe automated accessibility testing for linked items empty state' do
selector = '[data-testid="work-item-relationships"]'
expect(page).to be_axe_clean.within(selector).skipping :'link-in-text-block'
end
it 'passes axe automated accessibility testing for linked items' do
within_testid('work-item-relationships') do
click_button 'Add'
find_by_testid('work-item-token-select-input').set(objective.title)
wait_for_all_requests
form_selector = '[data-testid="work-item-relationships"]'
expect(page).to be_axe_clean.within(form_selector).skipping :'aria-input-field-name',
:'aria-required-children'
within_testid('link-work-item-form') do
click_button objective.title
click_button 'Add'
end
wait_for_all_requests
expect(page).to be_axe_clean.within(form_selector)
end
end
end
def verify_linked_item_added(input)
within_testid('work-item-relationships') do
click_button 'Add'
within_testid('link-work-item-form') do
expect(page).to have_button('Add', disabled: true)
find(token_input_selector).set(input)
wait_for_all_requests
click_button task.title
expect(page).to have_button('Add', disabled: false)
click_button 'Add'
wait_for_all_requests
end
expect(find('.work-items-list')).to have_content('Task 1')
end
end
end

View File

@ -24,6 +24,8 @@ RSpec.describe 'Work item detail', :js, feature_category: :team_planning do
let(:work_items_path) { project_work_item_path(project, work_item.iid) }
context 'for signed in user' do
let(:linked_item) { task }
before_all do
group.add_developer(user)
end
@ -46,6 +48,7 @@ RSpec.describe 'Work item detail', :js, feature_category: :team_planning do
it_behaves_like 'work items title'
it_behaves_like 'work items description'
it_behaves_like 'work items award emoji'
it_behaves_like 'work items linked items'
it_behaves_like 'work items comments', :issue
it_behaves_like 'work items toggle status button'

View File

@ -1,10 +1,10 @@
import { parseConfig } from '~/glql/core/parser/config';
import { parseYAMLConfig } from '~/glql/core/parser/config';
describe('parseConfig', () => {
describe('parseYAMLConfig', () => {
it('parses the frontmatter and returns an object', () => {
const frontmatter = 'fields: title, assignees, dueDate\ndisplay: list';
expect(parseConfig(frontmatter)).toEqual({
expect(parseYAMLConfig(frontmatter)).toEqual({
fields: [
{ name: 'title', label: 'Title', key: 'title' },
{ name: 'assignees', label: 'Assignees', key: 'assignees' },
@ -17,7 +17,7 @@ describe('parseConfig', () => {
it('returns default fields if none are provided', () => {
const frontmatter = 'display: list';
expect(parseConfig(frontmatter, { fields: ['title', 'assignees', 'dueDate'] })).toEqual({
expect(parseYAMLConfig(frontmatter, { fields: ['title', 'assignees', 'dueDate'] })).toEqual({
fields: [
{ name: 'title', label: 'Title', key: 'title' },
{ name: 'assignees', label: 'Assignees', key: 'assignees' },

View File

@ -1,6 +1,6 @@
import { parseQueryText } from '~/glql/core/parser';
import { parseQueryTextWithFrontmatter, parse } from '~/glql/core/parser';
describe('parseQueryText', () => {
describe('parseQueryTextWithFrontmatter', () => {
it('separates the presentation layer from the query and returns an object', () => {
const text = `---
fields: title, assignees, dueDate
@ -8,7 +8,7 @@ display: list
---
assignee = currentUser()`;
expect(parseQueryText(text)).toEqual({
expect(parseQueryTextWithFrontmatter(text)).toEqual({
frontmatter: 'fields: title, assignees, dueDate\ndisplay: list',
query: 'assignee = currentUser()',
});
@ -17,9 +17,179 @@ assignee = currentUser()`;
it('returns empty frontmatter if no frontmatter is present', () => {
const text = 'assignee = currentUser()';
expect(parseQueryText(text)).toEqual({
expect(parseQueryTextWithFrontmatter(text)).toEqual({
frontmatter: '',
query: 'assignee = currentUser()',
});
});
});
describe('parse', () => {
beforeEach(() => {
gon.current_username = 'root';
});
it('parses a simple query correctly', async () => {
expect(await parse('assignee = currentUser()')).toMatchInlineSnapshot(`
{
"config": {
"display": "list",
"fields": [
{
"key": "title",
"label": "Title",
"name": "title",
},
],
},
"query": "query GLQL {
issues(assigneeUsernames: "root", first: 100) {
nodes {
id
iid
title
webUrl
reference
state
type
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
",
}
`);
});
it('parses a query with frontmatter correctly', async () => {
expect(
await parse(`
---
fields: title, assignees, dueDate
display: table
---
assignee = currentUser()`),
).toMatchInlineSnapshot(`
{
"config": {
"display": "table",
"fields": [
{
"key": "title",
"label": "Title",
"name": "title",
},
{
"key": "assignees",
"label": "Assignees",
"name": "assignees",
},
{
"key": "dueDate",
"label": "Due date",
"name": "dueDate",
},
],
},
"query": "query GLQL {
issues(assigneeUsernames: "root", first: 100) {
nodes {
id
iid
title
webUrl
reference
state
type
assignees {
nodes {
id
avatarUrl
username
name
webUrl}
}
dueDate
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
",
}
`);
});
it('parses a YAML based query correctly', async () => {
expect(
await parse(`
fields: title, assignees, dueDate
display: table
limit: 20
query: assignee = currentUser()
`),
).toMatchInlineSnapshot(`
{
"config": {
"display": "table",
"fields": [
{
"key": "title",
"label": "Title",
"name": "title",
},
{
"key": "assignees",
"label": "Assignees",
"name": "assignees",
},
{
"key": "dueDate",
"label": "Due date",
"name": "dueDate",
},
],
"limit": 20,
},
"query": "query GLQL {
issues(assigneeUsernames: "root", first: 20) {
nodes {
id
iid
title
webUrl
reference
state
type
assignees {
nodes {
id
avatarUrl
username
name
webUrl}
}
dueDate
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
",
}
`);
});
});

View File

@ -5,8 +5,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CandidateList from '~/ml/model_registry/components/candidate_list.vue';
import SearchableList from '~/ml/model_registry/components/searchable_list.vue';
import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue';
import SearchableTable from '~/ml/model_registry/components/searchable_table.vue';
import getModelCandidatesQuery from '~/ml/model_registry/graphql/queries/get_model_candidates.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants';
import {
@ -22,8 +21,7 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
let wrapper;
let apolloProvider;
const findSearchableList = () => wrapper.findComponent(SearchableList);
const findAllRows = () => wrapper.findAllComponents(CandidateListRow);
const findSearchableTable = () => wrapper.findComponent(SearchableTable);
const mountComponent = ({
props = {},
@ -38,6 +36,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
modelId: 'gid://gitlab/Ml::Model/2',
...props,
},
stubs: {
SearchableTable,
},
});
};
@ -66,7 +67,7 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
});
it('is displayed', () => {
expect(findSearchableList().props('errorMessage')).toBe(
expect(findSearchableTable().props('errorMessage')).toBe(
'Failed to load model candidates with error: Failure!',
);
});
@ -83,21 +84,15 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
});
it('Passes items to list', () => {
expect(findSearchableList().props('items')).toEqual(graphqlCandidates);
expect(findSearchableTable().props('candidates')).toEqual(graphqlCandidates);
});
it('displays package version rows', () => {
expect(findAllRows()).toHaveLength(graphqlCandidates.length);
expect(findSearchableTable().props('candidates')).toHaveLength(graphqlCandidates.length);
});
it('binds the correct props', () => {
expect(findAllRows().at(0).props()).toMatchObject({
candidate: expect.objectContaining(graphqlCandidates[0]),
});
expect(findAllRows().at(1).props()).toMatchObject({
candidate: expect.objectContaining(graphqlCandidates[1]),
});
expect(findSearchableTable().props('candidates')).toEqual(graphqlCandidates);
});
});
@ -114,7 +109,7 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
});
it('when list emits fetch-page fetches the next set of records', async () => {
findSearchableList().vm.$emit('fetch-page', {
findSearchableTable().vm.$emit('fetch-page', {
after: 'eyJpZCI6IjIifQ',
first: 30,
id: 'gid://gitlab/Ml::Model/2',

View File

@ -0,0 +1,94 @@
import { mount } from '@vue/test-utils';
import { GlTableLite, GlBadge, GlLink } from '@gitlab/ui';
import CandidatesTable from '~/ml/model_registry/components/candidates_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { graphqlCandidates } from '../graphql_mock_data';
describe('CandidatesTable', () => {
let wrapper;
const createWrapper = (props = {}) => {
wrapper = mount(CandidatesTable, {
propsData: {
items: graphqlCandidates,
...props,
},
stubs: {
GlTableLite,
GlBadge,
GlLink,
TimeAgoTooltip,
},
});
};
const findGlTable = () => wrapper.findComponent(GlTableLite);
const findTableRows = () => wrapper.findAll('tbody tr');
beforeEach(() => {
createWrapper();
});
it('renders the table', () => {
expect(findGlTable().exists()).toBe(true);
});
it('has the correct columns in the table', () => {
expect(findGlTable().props('fields')).toEqual([
{ key: 'eid', label: 'MLflow Run ID', thClass: 'gl-w-2/8' },
{ key: 'ciJob', label: 'CI Job', thClass: 'gl-w-1/8' },
{ key: 'createdAt', label: 'Created', thClass: 'gl-w-1/8' },
{ key: 'status', label: 'Status', thClass: 'gl-w-1/8' },
]);
});
it('renders the correct number of rows', () => {
expect(findTableRows().length).toBe(2);
});
it('renders the correct information in the id column', () => {
const idCell = findTableRows().at(0).findAll('td').at(0);
expect(idCell.text()).toBe(graphqlCandidates[0].eid);
expect(idCell.findComponent(GlLink).attributes('href')).toBe(
graphqlCandidates[0]._links.showPath,
);
});
it('renders the correct information in the CI job column', () => {
const ciJobCell = findTableRows().at(0).findAll('td').at(1);
expect(ciJobCell.text()).toContain(graphqlCandidates[0].ciJob.name);
});
it('renders the correct information in the created column', () => {
const createdAtCell = findTableRows().at(0).findAll('td').at(2);
expect(createdAtCell.findComponent(TimeAgoTooltip).props('time')).toBe(
graphqlCandidates[0].createdAt,
);
});
it('renders the correct information in the status column', () => {
const statusCell = findTableRows().at(0).findAll('td').at(3);
expect(statusCell.text()).toContain(graphqlCandidates[0].status);
expect(statusCell.findComponent(GlBadge).props('variant')).toBe('info');
});
describe('when there is no CI Job information', () => {
beforeEach(() => {
createWrapper({ items: [{ ...graphqlCandidates[0], ciJob: null }] });
});
it('does not render content in CI Job column', () => {
const ciJobCell = findTableRows().at(0).findAll('td').at(1);
expect(ciJobCell.text()).toBe('');
});
it('does not render a badge in the CI Job column', () => {
const ciJobCell = findTableRows().at(0).findAll('td').at(1);
expect(ciJobCell.findComponent(GlBadge).exists()).toBe(false);
});
it('does not render a link in the CI Job column', () => {
const ciJobCell = findTableRows().at(0).findAll('td').at(1);
expect(ciJobCell.findComponent(GlLink).exists()).toBe(false);
});
});
});

View File

@ -3,18 +3,20 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SearchableTable from '~/ml/model_registry/components/searchable_table.vue';
import ModelVersionsTable from '~/ml/model_registry/components/model_versions_table.vue';
import ModelsTable from '~/ml/model_registry/components/models_table.vue';
import CandidatesTable from '~/ml/model_registry/components/candidates_table.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import { BASE_SORT_FIELDS } from '~/ml/model_registry/constants';
import * as urlHelpers from '~/lib/utils/url_utility';
import LoadOrErrorOrShow from '~/ml/model_registry/components/load_or_error_or_show.vue';
import { defaultPageInfo } from '../mock_data';
import { graphqlModelVersions, graphqlModels } from '../graphql_mock_data';
import { graphqlModelVersions, graphqlModels, graphqlCandidates } from '../graphql_mock_data';
describe('ml/model_registry/components/searchable_list.vue', () => {
describe('ml/model_registry/components/searchable_table.vue', () => {
let wrapper;
const findLoadOrErrorOrShow = () => wrapper.findComponent(LoadOrErrorOrShow);
const findModelsTable = () => wrapper.findComponent(ModelsTable);
const findCandidatesTable = () => wrapper.findComponent(CandidatesTable);
const findModelVersionsTable = () => wrapper.findComponent(ModelVersionsTable);
const findSearchableTable = () => wrapper.findComponent(SearchableTable);
const findEmptyState = () => wrapper.findByTestId('empty-state-slot');
@ -72,6 +74,7 @@ describe('ml/model_registry/components/searchable_list.vue', () => {
it('does not display registry list', () => {
expect(findModelVersionsTable().exists()).toBe(false);
expect(findModelsTable().exists()).toBe(false);
expect(findCandidatesTable().exists()).toBe(false);
});
it('Does not display error message', () => {
@ -101,6 +104,7 @@ describe('ml/model_registry/components/searchable_list.vue', () => {
it('displays model versions table', () => {
expect(findModelVersionsTable().exists()).toBe(true);
expect(findModelsTable().exists()).toBe(false);
expect(findCandidatesTable().exists()).toBe(false);
});
it('binds the right props', () => {
@ -133,6 +137,7 @@ describe('ml/model_registry/components/searchable_list.vue', () => {
it('displays model table', () => {
expect(findModelsTable().exists()).toBe(true);
expect(findModelVersionsTable().exists()).toBe(false);
expect(findCandidatesTable().exists()).toBe(false);
});
it('binds the right props', () => {
@ -159,6 +164,39 @@ describe('ml/model_registry/components/searchable_list.vue', () => {
});
});
describe('when list is loaded with candidates', () => {
beforeEach(() => mountComponent({ candidates: graphqlCandidates }));
it('displays candidates table', () => {
expect(findCandidatesTable().exists()).toBe(true);
expect(findModelVersionsTable().exists()).toBe(false);
expect(findModelsTable().exists()).toBe(false);
});
it('binds the right props', () => {
expect(findSearchableTable().props()).toMatchObject({
candidates: graphqlCandidates,
isLoading: false,
pageInfo: defaultPageInfo,
showSearch: false,
sortableFields: [],
canWriteModelRegistry: true,
});
});
it('displays candidate rows', () => {
expect(findCandidatesTable().props('items')).toHaveLength(2);
});
it('does not display loader', () => {
expect(findLoadOrErrorOrShow().props('isLoading')).toBe(false);
});
it('does not display empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
});
describe('when user interacts with pagination', () => {
beforeEach(() => mountComponent());

View File

@ -154,6 +154,12 @@ export const modelVersionWithCandidateAndNullAuthor = {
export const graphqlCandidates = [
{
id: 'gid://gitlab/Ml::Candidate/1',
eid: 'e9a71521-45c6-4b0a-b0c3-21f0b4528a5c',
ciJob: {
id: 'gid://gitlab/Ci::Build/1',
name: 'build:linux',
},
status: 'running',
name: 'narwhal-aardvark-heron-6953',
createdAt: '2023-12-06T12:41:48Z',
_links: {
@ -162,6 +168,12 @@ export const graphqlCandidates = [
},
{
id: 'gid://gitlab/Ml::Candidate/2',
eid: 'e9a71521-45c6-4b0a-b0c3-21f0b4528a4c',
ciJob: {
id: 'gid://gitlab/Ci::Build/2',
name: 'build:linux',
},
status: 'failed',
name: 'anteater-chimpanzee-snake-1254',
createdAt: '2023-12-06T12:41:48Z',
_links: {

View File

@ -84,6 +84,34 @@ RSpec.describe Gitlab::Auth::Identity, :request_store, feature_category: :system
end
end
describe '.link_from_scoped_user_id' do
let(:scoped_user_id) { scoped_user.id }
subject(:identity) { described_class.link_from_scoped_user_id(primary_user, scoped_user_id) }
context 'when composite identity is required for the actor' do
before do
allow(primary_user).to receive(:has_composite_identity?).and_return(true)
end
it 'returns an identity' do
expect(identity).to be_composite
expect(identity).to be_linked
expect(identity).to be_valid
expect(identity.scoped_user).to eq(scoped_user)
end
end
context 'when scoped_user_id is unknown' do
let(:scoped_user_id) { 0 }
it 'returns nil' do
expect(identity).to be_nil
end
end
end
describe '.fabricate' do
subject(:identity) { described_class.fabricate(primary_user) }

View File

@ -99,6 +99,19 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists, feature_category:
end
it { is_expected.to be_truthy }
it 'logs the pattern comparison limit exceeded' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(
class: described_class.name,
message: 'rules:exists pattern comparisons limit exceeded',
project_id: project.id,
paths_size: kind_of(Integer),
globs_size: 1,
comparisons: kind_of(Integer)
)
satisfied_by?
end
end
context 'when rules:exists:project is provided' do

View File

@ -78,6 +78,7 @@ RSpec.describe Gitlab::Ci::Config::External::Rules, feature_category: :pipeline_
context 'when there is a rule with exists:' do
let(:rule_hashes) { [{ exists: 'file.txt' }] }
let(:pipeline) { instance_double(Ci::Pipeline, project: project, project_id: project.id, sha: 'sha', id: 1) }
context 'when the file exists' do
let(:context) { double(top_level_worktree_paths: ['file.txt']) }

View File

@ -540,6 +540,7 @@ protected_branches:
- approval_project_rules_with_unique_policies
- external_status_checks
- required_code_owners_sections
- squash_option
protected_tags:
- project
- create_access_levels

View File

@ -312,4 +312,19 @@ RSpec.describe Gitlab::Pages::UrlBuilder, feature_category: :pages do
it { is_expected.to eq('example.com') }
end
end
describe "is_namespace_homepage?" do
subject { described_class.new(project).is_namespace_homepage? }
where(:full_path, :result) do
'group/foo' | false
'group/group.example.com' | true
'grOUP/group.example.com' | true
'group/Group.example.com' | true
end
with_them do
it { is_expected.to eq(result) }
end
end
end

View File

@ -82,7 +82,7 @@ RSpec.describe API::Pages, feature_category: :pages do
"created_at" => created_at.strftime('%Y-%m-%dT%H:%M:%S.%3LZ'),
"path_prefix" => nil,
"root_directory" => "public",
"url" => "http://unique-domain.example.com/"
"url" => "http://unique-domain.example.com"
}
])
end

View File

@ -6,14 +6,14 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
using RSpec::Parameterized::TableSyntax
include EmailHelpers
let(:error_status) { { status: :error, message: "Couldn't verify #{domain.domain}" } }
subject(:service) { described_class.new(domain) }
let(:service) { described_class.new(domain) }
describe '#execute' do
subject(:service_response) { service.execute }
where(:domain_sym, :code_sym) do
:domain | :verification_code
:domain | :keyed_verification_code
:domain | :verification_code
:domain | :keyed_verification_code
:verification_domain | :verification_code
:verification_domain | :keyed_verification_code
@ -24,8 +24,10 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
let(:verification_code) { domain.send(code_sym) }
shared_examples 'verifies and enables the domain' do
it_behaves_like 'returning a success service response'
it 'verifies and enables the domain' do
expect(service.execute).to eq(status: :success)
service_response
expect(domain).to be_verified
expect(domain).to be_enabled
@ -52,13 +54,19 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
end
shared_examples 'unverifies and disables domain' do
let(:error_message) { "Couldn't verify #{domain.domain}" }
it_behaves_like 'returning an error service response'
it { is_expected.to have_attributes message: error_message }
it 'unverifies domain' do
expect(service.execute).to eq(error_status)
service_response
expect(domain).not_to be_verified
end
it 'disables domain and shedules it for removal in 1 week' do
service.execute
service_response
expect(domain).not_to be_enabled
@ -66,7 +74,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
end
end
context 'when domain is disabled(or new)' do
context 'when domain is disabled (or new)' do
let(:domain) { create(:pages_domain, :disabled) }
include_examples 'successful enablement and verification'
@ -79,14 +87,6 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
include_examples 'unverifies and disables domain'
end
context 'when txt record does not contain verification code' do
before do
stub_resolver(domain_name => 'something else')
end
include_examples 'unverifies and disables domain'
end
context 'when no txt records are present' do
before do
stub_resolver
@ -102,14 +102,19 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
include_examples 'successful enablement and verification'
shared_examples 'unverifing domain' do
it_behaves_like 'returning an error service response'
it { is_expected.to have_attributes message: "Couldn't verify #{domain.domain}" }
it 'unverifies but does not disable domain' do
expect(service.execute).to eq(error_status)
service_response
expect(domain).not_to be_verified
expect(domain).to be_enabled
end
it 'does not schedule domain for removal' do
service.execute
service_response
expect(domain.remove_at).to be_nil
end
end
@ -147,9 +152,9 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
stub_resolver
end
let(:error_status) { { status: :error, message: "Couldn't verify #{domain.domain}. It is now disabled." } }
include_examples 'unverifies and disables domain'
include_examples 'unverifies and disables domain' do
let(:error_message) { "Couldn't verify #{domain.domain}. It is now disabled." }
end
end
end
@ -161,15 +166,17 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
stub_resolver(domain.domain => domain.keyed_verification_code)
end
it_behaves_like 'returning a success service response'
it 'verifies and enables domain' do
expect(service.execute).to eq(status: :success)
service_response
expect(domain).to be_verified
expect(domain).to be_enabled
end
it 'prevent domain from being removed' do
expect { service.execute }.to change { domain.remove_at }.to(nil)
expect { service_response }.to change { domain.remove_at }.to(nil)
end
end
@ -179,7 +186,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
end
it 'keeps domain scheduled for removal but does not change removal time' do
expect { service.execute }.not_to change { domain.remove_at }
expect { service_response }.not_to change { domain.remove_at }
expect(domain.remove_at).to be_present
end
end
@ -190,14 +197,14 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
before do
domain.save!(validate: false)
stub_resolver
end
it_behaves_like 'returning an error service response'
it { is_expected.to have_attributes(message: "Couldn't verify #{domain.domain}. It is now disabled.") }
it 'can be disabled' do
error_status[:message] += '. It is now disabled.'
stub_resolver
expect(service.execute).to eq(error_status)
service_response
expect(domain).not_to be_verified
expect(domain).not_to be_enabled
@ -211,7 +218,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
it 'sets a timeout on the DNS query' do
expect(stub_resolver).to receive(:timeouts=).with(described_class::RESOLVER_TIMEOUT_SECONDS)
service.execute
service_response
end
end
@ -235,7 +242,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
let(:domain) { create(:pages_domain, *[factory].compact) }
before do
allow(service).to receive(:notification_service) { notification_service }
allow(NotificationService).to receive(:new).and_return(notification_service)
if verification_succeeds
stub_resolver(domain.domain => domain.verification_code)
@ -249,7 +256,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
expect(notification_service).to receive(:"pages_domain_#{expected_notification}").with(domain)
end
service.execute
service_response
end
end
@ -257,28 +264,28 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
let(:domain) { create(:pages_domain, :disabled) }
before do
allow(NotificationService).to receive(:new).and_return(notification_service)
stub_application_setting(pages_domain_verification_enabled: false)
allow(service).to receive(:notification_service) { notification_service }
end
it 'skips email notifications' do
expect(notification_service).not_to receive(:pages_domain_enabled)
service.execute
service_response
end
end
end
context 'no verification code' do
let(:domain) { create(:pages_domain) }
it 'returns an error' do
domain.verification_code = ''
let(:domain) { build(:pages_domain, verification_code: '') }
before do
disallow_resolver!
expect(service.execute).to eq(status: :error, message: "No verification code set for #{domain.domain}")
end
it_behaves_like 'returning an error service response'
it { is_expected.to have_attributes(message: "No verification code set for #{domain.domain}") }
end
context 'pages domain verification is disabled' do
@ -291,7 +298,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
it 'extends domain validity by unconditionally reverifying' do
disallow_resolver!
service.execute
service_response
expect(domain).to be_verified
expect(domain).to be_enabled
@ -302,7 +309,7 @@ RSpec.describe VerifyPagesDomainService, feature_category: :pages do
domain.update!(enabled_until: grace)
disallow_resolver!
service.execute
service_response
expect(domain.enabled_until).to be_like_time(grace)
end

View File

@ -6,7 +6,7 @@ module ProtectedBranchHelpers
page.driver.browser.manage.window.maximize
# Make sure dropdown is in view
execute_script('window.scrollTo(0,0)')
execute_script("document.querySelector('#{form}').scrollIntoView({ block: 'start' })")
within(form) do
within_select(".js-allowed-to-#{operation}:not([disabled])") do

View File

@ -866,3 +866,115 @@ RSpec.shared_examples 'work items hierarchy' do |testid, type|
click_button "Create #{type}"
end
end
RSpec.shared_examples 'work items linked items' do |is_group = false|
it 'are not displayed when issue does not have work item links', :aggregate_failures do
within_testid('work-item-relationships') do
expect(page).to have_selector('[data-testid="link-item-add-button"]')
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
expect(page).not_to have_selector('[data-testid="work-item-linked-items-list"]')
end
end
it 'toggles widget body and form', :aggregate_failures do
within_testid('work-item-relationships') do
expect(page).to have_selector('[data-testid="crud-empty"]')
click_button 'Collapse'
expect(page).not_to have_selector('[data-testid="crud-empty"]')
click_button 'Expand'
expect(page).to have_selector('[data-testid="crud-empty"]')
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
click_button 'Add'
expect(page).to have_selector('[data-testid="link-work-item-form"]')
click_button 'Cancel'
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
end
end
it 'links a new item with work item text', :aggregate_failures do
expect_linked_item_added(linked_item.title)
end
it 'links a new item with work item iid', :aggregate_failures do
expect_linked_item_added(linked_item.iid)
end
it 'links a new item with work item wildcard iid', :aggregate_failures do
expect_linked_item_added("##{linked_item.iid}")
end
it 'links a new item with work item url', :aggregate_failures do
url = if is_group
"#{Gitlab.config.gitlab.url}/groups/#{linked_item.namespace.full_path}/-/work_items/#{linked_item.iid}"
else
"#{Gitlab.config.gitlab.url}/#{linked_item.project.full_path}/-/work_items/#{linked_item.iid}"
end
expect_linked_item_added(url)
end
it 'removes a linked item', :aggregate_failures do
within_testid('work-item-relationships') do
click_button 'Add'
within_testid('link-work-item-form') do
fill_in 'Search existing items', with: linked_item.title
click_button linked_item.title
click_button 'Add'
end
expect(page).to have_link linked_item.title
find_link(linked_item.title).hover
click_button 'Remove', match: :first
expect(page).not_to have_link linked_item.title
end
end
it 'passes axe automated accessibility testing for linked items', :aggregate_failures do
selector = '[data-testid="work-item-relationships"]'
within_testid('work-item-relationships') do
expect(page).to be_axe_clean.within(selector).skipping :'link-in-text-block'
click_button 'Add'
fill_in 'Search existing items', with: linked_item.title
expect(page).to be_axe_clean.within(selector).skipping :'aria-input-field-name',
:'aria-required-children'
within_testid('link-work-item-form') do
click_button linked_item.title
click_button 'Add'
end
expect(page).to be_axe_clean.within(selector)
end
end
def expect_linked_item_added(input)
within_testid('work-item-relationships') do
click_button 'Add'
within_testid('link-work-item-form') do
expect(page).to have_button('Add', disabled: true)
fill_in 'Search existing items', with: input
click_button linked_item.title, match: :first
click_button 'Add'
end
expect(page).to have_link linked_item.title
end
end
end

View File

@ -1355,10 +1355,10 @@
core-js "^3.29.1"
mitt "^3.0.1"
"@gitlab/duo-ui@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/duo-ui/-/duo-ui-5.0.0.tgz#f803a8c10400b7eccaa08451ca8c5149b556b5b5"
integrity sha512-ojeNHENJrx6MZeUskm6DisGAT2BXKRn8UjRx3zldsxZJBpwU55mVsTrzQa1iR1ilvy9RrPbGeWRT4jbAH7n3kg==
"@gitlab/duo-ui@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/duo-ui/-/duo-ui-3.0.0.tgz#923ef334f88a473b32a4fce267060f23a00de9ed"
integrity sha512-e+BjHZsefmvYRj5+uFcxZs1/FhwCVa1KvRxqKntFS9nARgwpYr+HzDfDb+30SG+CvJNsdWIebkOnuLEKfKvH1w==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"
@ -1369,7 +1369,6 @@
popper.js "^1.16.1"
portal-vue "^2.1.7"
vue-functional-data-merge "^3.1.0"
vue-resizable "1.3.4"
vue-runtime-helpers "^1.1.2"
"@gitlab/eslint-plugin@20.6.0":
@ -1398,8 +1397,7 @@
resolved "https://registry.yarnpkg.com/@gitlab/fonts/-/fonts-1.3.0.tgz#df89c1bb6714e4a8a5d3272568aa4de7fb337267"
integrity sha512-DoMUIN3DqjEn7wvcxBg/b7Ite5fTdF5EmuOZoBRo2j0UBGweDXmNBi+9HrTZs4cBU660dOxcf1hATFcG3npbPg==
"@gitlab/noop@^1.0.0", jackspeak@^2.3.5, "jackspeak@npm:@gitlab/noop@1.0.0":
name jackspeak
"@gitlab/noop@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/noop/-/noop-1.0.0.tgz#b1ecb8ae6b2abf9b2e28927e4fbb05b7a1b2704b"
integrity sha512-nOltttik5o2BjBo8LnyeTFzHoLpMY/XcCVOC+lm9ZwU+ivEam8wafacMF0KTbRn1KVrIoHYdo70QnqS+vJiOVw==
@ -1423,15 +1421,17 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.121.0.tgz#57cacc895929aef4320632396373797a64b230ff"
integrity sha512-ZekVjdMZrjrNEjdrOHsJYCu7A+ea3AkuNUxWIZ3FaNgJj4Oh21RlTP7bQKnRSXVhBbV1jg1PgzQ1ANEoCW8t4g==
"@gitlab/ui@105.1.1":
version "105.1.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-105.1.1.tgz#deb27dbe0a00bbdf88300848d01e39b7814524e0"
integrity sha512-5AOVdqy3NL/LIUCEnsgkpbDjYcZJbYWUOnu9qi19wSFzwuvZN+Pb94firIkB/8dwmxTwPM7RDg3HU0msUTBt7Q==
"@gitlab/ui@104.2.0":
version "104.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-104.2.0.tgz#074db0e83ee8a05afa69b0099c750c4df48d55e4"
integrity sha512-CUTHvQRYFpCAyCLpY/D/eKiOMMaOzuCmjqDLNLieU6Uh6AkS49orSBNNNs3bUSS163r+LNx/NsTrHDLA5NxAcA==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"
iframe-resizer "^4.3.2"
lodash "^4.17.20"
marked "^12.0.0"
marked-bidi "^1.0.8"
popper.js "^1.16.1"
portal-vue "^2.1.7"
vue-functional-data-merge "^3.1.0"
@ -9261,6 +9261,11 @@ iterall@^1.2.1:
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea"
integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==
jackspeak@^2.3.5, "jackspeak@npm:@gitlab/noop@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/noop/-/noop-1.0.0.tgz#b1ecb8ae6b2abf9b2e28927e4fbb05b7a1b2704b"
integrity sha512-nOltttik5o2BjBo8LnyeTFzHoLpMY/XcCVOC+lm9ZwU+ivEam8wafacMF0KTbRn1KVrIoHYdo70QnqS+vJiOVw==
jed@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4"
@ -14924,11 +14929,6 @@ vue-observe-visibility@^1.0.0:
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
vue-resizable@1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/vue-resizable/-/vue-resizable-1.3.4.tgz#04cbaeb7f90ebb92970b774f81f590832c640e5e"
integrity sha512-Ub28/auJPaXObqpakCZABxoiZnJlSyJx65SJ7EwCu54rOJda2fgWj5ZiqvHa+xOuBGH6yemYPKwuO761Cey0Bg==
vue-resize@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-1.0.1.tgz#c120bed4e09938771d622614f57dbcf58a5147ee"