Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-02-22 18:10:08 +00:00
parent 54b2cc7dfc
commit 9db4bab965
65 changed files with 773 additions and 1985 deletions

View File

@ -45,59 +45,17 @@ db:rollback single-db:
- .single-db
- .rails:rules:single-db
db:migrate:multi-version-upgrade-1:
stage: test
image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/${BUILD_OS}-${OS_VERSION}-ruby-${RUBY_VERSION}:bundler-${BUNDLER_VERSION}-docker-${DOCKER_VERSION}
extends:
- .db-job-base
- .use-docker-in-docker
variables:
UPGRADE_STOP: 16.3.6-ee
UPGRADE_STOP_IMAGE: gitlab/gitlab-ee:${UPGRADE_STOP}.0
UPGRADE_STOP_TAG: v${UPGRADE_STOP}
before_script:
# pull, seed, and export data from previous Upgrade Stop
- docker pull "${UPGRADE_STOP_IMAGE}"
- |
docker run \
-d \
-v ./scripts/data_seeder:/opt/gitlab/embedded/service/gitlab-rails/scripts/data_seeder \
-v ./ee/db/seeds/data_seeder:/opt/gitlab/embedded/service/gitlab-rails/ee/db/seeds/data_seeder \
-v ./ee/lib/tasks/gitlab/seed:/opt/gitlab/embedded/service/gitlab-rails/ee/lib/tasks/gitlab/seed \
--name gitlab \
"${UPGRADE_STOP_IMAGE}"
- docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; REF='${UPGRADE_STOP_TAG}' . scripts/data_seeder/test_resources.sh"
- |
docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; echo \"gem 'gitlab-rspec', path: 'gems/gitlab-rspec'\" >> Gemfile"
- docker exec gitlab bash -c "cd /opt/gitlab/embedded/service/gitlab-rails; ruby scripts/data_seeder/globalize_gems.rb; bundle install"
- docker exec gitlab bash -c "gitlab-ctl reconfigure"
- docker exec gitlab gitlab-rake "ee:gitlab:seed:data_seeder[bulk_data.rb]"
# dump
- docker exec gitlab bash -c "mkdir /tmp/xfer; chown gitlab-psql /tmp/xfer"
- |
docker exec gitlab bash -c " \
runuser -l gitlab-psql -c \"pg_dump -U gitlab-psql -h '/var/opt/gitlab/postgresql' gitlabhq_production | gzip > /tmp/xfer/gitlabhq_production.gz\" \
"
script:
- docker cp gitlab:/tmp/xfer/gitlabhq_production.gz .
artifacts:
paths: ["gitlabhq_production.gz"]
expire_in: 3d
when: manual
allow_failure: true
db:migrate:multi-version-upgrade-2:
stage: test
db:migrate:multi-version-upgrade:
extends:
- .db-job-base
script:
- gunzip gitlabhq_production.gz
- curl -o latest_upgrade_stop.gz https://gitlab.com/gitlab-org/quality/pg-dump-generator/-/raw/main/pg_dumps/ee/latest_upgrade_stop.gz
- gunzip -c latest_upgrade_stop.gz > gitlabhq_production
- bundle exec rake db:drop db:create
- apt-get update -qq && apt-get install -y -qq postgresql
- psql -h postgres -U postgres -d gitlabhq_test < gitlabhq_production
- bundle exec rake gitlab:db:configure
needs: ["db:migrate:multi-version-upgrade-1"]
allow_failure: true
db:migrate:reset:
extends: .db-job-base

View File

@ -161,9 +161,6 @@
.if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified: &if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && ($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "detached") && $QA_TESTS'
.if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e: &if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && ($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "detached") && $QA_MANUAL_FF_PACKAGE_AND_QA'
.if-dot-com-gitlab-org-and-security-tag: &if-dot-com-gitlab-org-and-security-tag
if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/security$)/ && $CI_COMMIT_TAG'
@ -898,8 +895,6 @@
- <<: *if-merge-request
changes: *dependency-patterns
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
@ -985,9 +980,6 @@
changes: *nodejs-patterns
- <<: *if-merge-request
changes: *ci-qa-patterns
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
when: manual
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
changes: *code-patterns
- <<: *if-merge-request
@ -1016,8 +1008,6 @@
- <<: *if-merge-request
changes: *dependency-patterns
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
@ -1252,8 +1242,6 @@
# Rules to support .qa:rules:package-and-test
- <<: *if-default-branch-schedule-nightly
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
changes: *dependency-patterns
- <<: *if-force-ci
@ -1533,9 +1521,6 @@
allow_failure: true
- <<: *if-merge-request-labels-run-all-e2e
allow_failure: true
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
allow_failure: true
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
allow_failure: true
@ -1561,8 +1546,6 @@
variables:
OMNIBUS_GITLAB_BUILD_ON_ALL_OS: 'true'
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
- <<: *if-merge-request
@ -1605,10 +1588,6 @@
OMNIBUS_GITLAB_BUILD_ON_ALL_OS: 'true'
- <<: *if-merge-request-labels-run-all-e2e
allow_failure: true
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
when: manual
allow_failure: true
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
allow_failure: true
@ -1773,43 +1752,6 @@
- <<: *if-merge-request-and-specific-devops-stage
when: never
- !reference [".qa:rules:code-merge-request-manual", rules]
- <<: *if-dot-com-gitlab-org-schedule
when: never
- <<: *if-merge-request-targeting-stable-branch
changes: *setup-test-env-patterns
when: never
- <<: *if-ruby-branch
when: never
- <<: *if-merge-request
changes: *dependency-patterns
when: never
- <<: *if-merge-request-labels-run-all-e2e
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
when: manual
allow_failure: true
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
when: never
- <<: *if-merge-request
changes: *initializers-patterns
when: never
- <<: *if-merge-request
changes: *nodejs-patterns
when: never
- <<: *if-merge-request
changes: *ci-qa-patterns
when: never
- <<: *if-merge-request
changes: *qa-patterns
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
changes: *code-patterns
when: never
- <<: *if-force-ci
when: manual
allow_failure: true
# These are based on `.qa:rules:manual-omnibus-and-follow-up-e2e` but with manual jobs changed to automatic
.qa:rules:follow-up-e2e:
@ -1820,41 +1762,6 @@
- <<: *if-merge-request
changes: *code-patterns
allow_failure: true
- <<: *if-dot-com-gitlab-org-schedule
when: never
- <<: *if-merge-request-targeting-stable-branch
changes: *setup-test-env-patterns
when: never
- <<: *if-ruby-branch
when: never
- <<: *if-merge-request
changes: *dependency-patterns
when: never
- <<: *if-merge-request-labels-run-all-e2e
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
allow_failure: true
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
when: never
- <<: *if-merge-request
changes: *initializers-patterns
when: never
- <<: *if-merge-request
changes: *nodejs-patterns
when: never
- <<: *if-merge-request
changes: *ci-qa-patterns
when: never
- <<: *if-merge-request
changes: *qa-patterns
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified
changes: *code-patterns
when: never
- <<: *if-force-ci
allow_failure: true
# These are based on `qa:rules:package-and-test-ee` but with when:never in all except for code-patterns in merge requests
.qa:rules:post-run-e2e-message:
@ -1871,9 +1778,6 @@
when: never
- <<: *if-merge-request-labels-run-all-e2e
when: never
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
when: never
- <<: *if-merge-request
changes: *feature-flag-development-config-patterns
when: never
@ -3026,8 +2930,6 @@
changes: *code-backstage-qa-patterns
- <<: *if-security-merge-request
changes: *db-patterns
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
.as-if-foss:rules:start-as-if-foss:allow-failure:manual:
rules:
@ -3126,10 +3028,6 @@
changes: *db-patterns
allow_failure: true
when: manual
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
allow_failure: true
when: manual
.as-if-foss:rules:start-as-if-foss:allow-failure:
rules:
@ -3205,9 +3103,6 @@
- <<: *if-security-merge-request
changes: *db-patterns
allow_failure: true
- <<: *if-dot-com-gitlab-org-and-security-merge-request-manual-ff-package-and-e2e
changes: *feature-flag-development-config-patterns
allow_failure: true
##################
# as-if-jh rules #

View File

@ -20,7 +20,6 @@ import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes
import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
import { removeArtifactFromStore } from '../graphql/cache_update';
import {
STATUS_BADGE_VARIANTS,
I18N_DOWNLOAD,
I18N_BROWSE,
I18N_DELETE,
@ -351,7 +350,6 @@ export default {
tdClass: 'gl-text-right',
},
],
STATUS_BADGE_VARIANTS,
i18n: {
download: I18N_DOWNLOAD,
browse: I18N_BROWSE,

View File

@ -4,29 +4,6 @@ export const PAGE_TITLE = s__('Artifacts|Artifacts');
export const TOTAL_ARTIFACTS_SIZE = s__('Artifacts|Total artifacts size');
export const SIZE_UNKNOWN = __('Unknown');
export const JOB_STATUS_GROUP_SUCCESS = 'success';
export const STATUS_BADGE_VARIANTS = {
success: 'success',
passed: 'success',
error: 'danger',
failed: 'danger',
pending: 'warning',
'waiting-for-resource': 'warning',
'failed-with-warnings': 'warning',
'success-with-warnings': 'warning',
running: 'info',
canceled: 'neutral',
disabled: 'neutral',
scheduled: 'neutral',
manual: 'neutral',
notification: 'muted',
preparing: 'muted',
created: 'muted',
skipped: 'muted',
notfound: 'muted',
};
export const I18N_DOWNLOAD = __('Download');
export const I18N_BROWSE = s__('Artifacts|Browse');
export const I18N_DELETE = __('Delete');

View File

@ -1,5 +1,5 @@
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants';
import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE } from './constants';
export const totalArtifactsSizeForJob = (job) =>
numberToHumanSize(
@ -19,7 +19,6 @@ export const mapArchivesToJobNodes = (jobNode) => {
export const mapBooleansToJobNodes = (jobNode) => {
return {
succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS,
hasArtifacts: jobNode.artifacts.nodes.length > 0,
hasMetadata: jobNode.artifacts.nodes.some(
(artifact) => artifact.fileType === METADATA_FILE_TYPE,

View File

@ -88,7 +88,7 @@ export default {
</script>
<template>
<node-view-wrapper
v-if="!isStaleUploadedImage"
v-show="!isStaleUploadedImage"
as="span"
class="gl-relative gl-display-inline-block"
>

View File

@ -44,7 +44,7 @@ export default {
</script>
<template>
<node-view-wrapper
v-if="!isStaleUploadedMedia"
v-show="!isStaleUploadedMedia"
as="span"
:class="`media-container ${node.type.name}-container`"
>

View File

@ -126,7 +126,7 @@ export default {
};
},
sort() {
return `${this.sortName}_${this.sortDirection}`.toUpperCase();
return `${this.sortName}_${this.sortDirection}`;
},
isLoading() {
return this.$apollo.queries.groups.loading;

View File

@ -2,7 +2,7 @@
query getOrganizationGroups(
$id: OrganizationsOrganizationID!
$search: String
$sort: OrganizationGroupSort
$sort: String
$first: Int
$last: Int
$before: String

View File

@ -80,7 +80,7 @@ export default {
<template>
<div>
<gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
<template #title>
<template v-if="popover.title" #title>
<span v-if="popover.html" v-safe-html:[$options.safeHtmlConfig]="popover.title"></span>
<span v-else>{{ popover.title }}</span>
</template>

View File

@ -1 +0,0 @@
Please see [the Widget Extensions documentation](development/merge_request_concepts/widget_extensions.md) for necessary information regarding development of new MR Widgets.

View File

@ -1,417 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import { sprintf, s__, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import Actions from '../action_buttons.vue';
import StateContainer from '../state_container.vue';
import { generateText } from '../widget/utils';
import { createTelemetryHub } from '../widget/telemetry';
import StatusIcon from './status_icon.vue';
import ChildContent from './child_content.vue';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
collapsedError: 'collapsedError',
expandedLoading: 'expandedLoading',
expandedError: 'expandedError',
};
export default {
telemetry: true,
components: {
GlButton,
GlLoadingIcon,
GlIntersectionObserver,
StatusIcon,
Actions,
ChildContent,
DynamicScroller,
DynamicScrollerItem,
StateContainer,
},
directives: {
SafeHtml,
GlTooltip: GlTooltipDirective,
},
data() {
return {
loadingState: LOADING_STATES.collapsedLoading,
collapsedData: {},
fullData: [],
isCollapsed: true,
showFade: false,
modalData: undefined,
modalName: undefined,
telemetry: null,
};
},
computed: {
widgetLabel() {
return this.$options.i18n?.label || this.$options.name;
},
widgetLoadingText() {
return this.$options.i18n?.loading || __('Loading...');
},
widgetErrorText() {
return this.$options.i18n?.error || __('Failed to load');
},
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
isLoadingExpanded() {
return this.loadingState === LOADING_STATES.expandedLoading;
},
isCollapsible() {
if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) {
if ('shouldCollapse' in this) {
return this.shouldCollapse(this.collapsedData);
}
return true;
}
return false;
},
hasFullData() {
return this.fullData.length > 0;
},
hasFetchError() {
return (
this.loadingState === LOADING_STATES.collapsedError ||
this.loadingState === LOADING_STATES.expandedError
);
},
collapseButtonLabel() {
return sprintf(
this.isCollapsed
? s__('mrWidget|Show %{widget} details')
: s__('mrWidget|Hide %{widget} details'),
{ widget: this.widgetLabel },
);
},
statusIconName() {
if (this.hasFetchError) return EXTENSION_ICONS.failed;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
},
tertiaryActionsButtons() {
return 'tertiaryButtons' in this ? this.tertiaryButtons() : undefined;
},
hydratedSummary() {
const structuredOutput = this.summary(this.collapsedData);
const summary = {
subject: generateText(
typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject,
),
};
if (structuredOutput.meta) {
summary.meta = generateText(structuredOutput.meta);
}
return summary;
},
modalId() {
return this.modalName || `modal${this.$options.name}`;
},
},
watch: {
isCollapsed(newVal) {
if (!newVal) {
this.loadAllData();
} else {
this.loadingState = null;
}
},
},
created() {
if (this.$options.telemetry) {
this.telemetry = createTelemetryHub(this.$options.name);
}
},
mounted() {
this.loadCollapsedData();
this.telemetry?.viewed();
},
methods: {
toggleCollapsed(e) {
if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) {
if (this.isCollapsed) {
this.telemetry?.expanded({ type: this.statusIconName });
}
this.isCollapsed = !this.isCollapsed;
}
},
initExtensionMultiPolling() {
const allData = [];
const requests = this.fetchMultiData();
requests.forEach((request) => {
const poll = new Poll({
resource: {
fetchData: () => request(this),
},
method: 'fetchData',
successCallback: (response) => {
this.headerCheck(response, (data) => allData.push(data));
if (allData.length === requests.length) {
this.setCollapsedData(allData);
}
},
errorCallback: (e) => {
this.setCollapsedError(e);
},
});
poll.makeRequest();
});
},
initExtensionPolling() {
const poll = new Poll({
resource: {
fetchData: () => this.fetchCollapsedData(this),
},
method: 'fetchData',
successCallback: (response) => {
this.headerCheck(response, (data) => this.setCollapsedData(data));
},
errorCallback: (e) => {
this.setCollapsedError(e);
},
});
poll.makeRequest();
},
initExtensionFullDataPolling() {
const poll = new Poll({
resource: {
fetchData: () => this.fetchFullData(this),
},
method: 'fetchData',
successCallback: (response) => {
this.headerCheck(response, (data) => {
this.setFullData(data);
});
},
errorCallback: (e) => {
this.setExpandedError(e);
},
});
poll.makeRequest();
},
headerCheck(response, callback) {
const headers = normalizeHeaders(response.headers);
if (!headers['POLL-INTERVAL']) {
callback(response.data);
}
},
loadCollapsedData() {
this.loadingState = LOADING_STATES.collapsedLoading;
if (this.$options.enablePolling) {
if (this.fetchMultiData) {
this.initExtensionMultiPolling();
} else {
this.initExtensionPolling();
}
} else {
this.fetchCollapsedData(this)
.then((data) => {
this.setCollapsedData(data);
})
.catch((e) => {
this.setCollapsedError(e);
});
}
},
setFullData(data) {
this.loadingState = null;
this.fullData = data.map((x, i) => ({ id: i, ...x }));
},
setCollapsedData(data) {
this.collapsedData = data;
this.loadingState = null;
},
setCollapsedError(e) {
this.loadingState = LOADING_STATES.collapsedError;
Sentry.captureException(e);
},
setExpandedError(e) {
this.loadingState = LOADING_STATES.expandedError;
Sentry.captureException(e);
},
loadAllData() {
if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
if (this.$options.enableExpandedPolling) {
this.initExtensionFullDataPolling();
} else {
this.fetchFullData(this)
.then((data) => {
this.setFullData(data);
})
.catch((e) => {
this.setExpandedError(e);
});
}
},
appear(index) {
if (index === this.fullData.length - 1) {
this.showFade = false;
}
},
disappear(index) {
if (index === this.fullData.length - 1) {
this.showFade = true;
}
},
onRowMouseDown() {
this.down = Number(new Date());
},
onRowMouseUp(e) {
const up = Number(new Date());
// To allow for text to be selected we check if the the user is clicking
// or selecting, if they are selecting the time difference should be
// more than 200ms
if (up - this.down < 200 && !e?.target?.closest('.btn-icon')) {
this.toggleCollapsed(e);
}
},
onClickedAction(action) {
if (action.trackFullReportClicked) {
this.telemetry?.fullReportClicked();
}
},
generateText,
},
EXTENSION_ICON_CLASS,
};
</script>
<template>
<section class="media-section" data-testid="widget-extension">
<state-container
:status="statusIconName"
:is-loading="isLoadingSummary"
:class="{ 'gl-cursor-pointer': isCollapsible }"
class="gl-pl-5 gl-pr-4 gl-py-4"
@mousedown="onRowMouseDown"
@mouseup="onRowMouseUp"
>
<div
:class="{ 'gl-h-full': isLoadingSummary }"
class="media-body gl-display-flex gl-flex-direction-row! gl-w-full"
data-testid="widget-extension-top-level"
>
<div
class="gl-flex-grow-1 gl-display-flex gl-align-items-center gl-flex-wrap"
data-testid="widget-extension-top-level-summary"
>
<div v-if="isLoadingSummary" class="gl-w-full gl-line-height-normal">
{{ widgetLoadingText }}
</div>
<div v-else-if="hasFetchError" class="gl-w-full gl-line-height-normal">
{{ widgetErrorText }}
</div>
<template v-else>
<div
v-safe-html="hydratedSummary.subject"
class="gl-w-full gl-line-height-normal"
></div>
<template v-if="hydratedSummary.meta">
<div
v-safe-html="hydratedSummary.meta"
class="gl-w-full gl-font-sm gl-line-height-normal"
></div>
</template>
</template>
</div>
<actions :tertiary-buttons="tertiaryActionsButtons" @clickedAction="onClickedAction" />
<div
v-if="isCollapsible"
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
v-gl-tooltip
:title="collapseButtonLabel"
:aria-expanded="`${!isCollapsed}`"
:aria-label="collapseButtonLabel"
:icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
data-testid="toggle-button"
size="small"
@click="toggleCollapsed"
/>
</div>
</div>
</state-container>
<div
v-if="!isCollapsed"
class="mr-widget-grouped-section gl-relative"
data-testid="widget-extension-collapsed-section"
>
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
<dynamic-scroller
v-else-if="hasFullData"
:items="fullData"
:min-item-size="32"
class="report-block-container gl-p-0"
>
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<div
:class="{
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
}"
class="gl-py-3 gl-pl-9"
data-testid="extension-list-item"
>
<gl-intersection-observer
:options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
rootMargin: '100px',
thresholds: 0.1,
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
class="gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
<child-content
:data="item"
:widget-label="widgetLabel"
:modal-id="modalId"
:level="2"
@clickedAction="onClickedAction"
/>
</gl-intersection-observer>
</div>
</dynamic-scroller-item>
</template>
</dynamic-scroller>
<div
:class="{ show: showFade }"
class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none"
></div>
</div>
<div v-if="$options.modalComponent && modalData">
<component :is="$options.modalComponent" :modal-id="modalId" v-bind="modalData" />
</div>
</section>
</template>

View File

@ -1,136 +0,0 @@
<script>
import { GlBadge, GlLink, GlModalDirective } from '@gitlab/ui';
import { isArray } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
import Actions from '../action_buttons.vue';
import { generateText } from '../widget/utils';
import StatusIcon from './status_icon.vue';
export default {
name: 'ChildContent',
components: {
GlBadge,
GlLink,
StatusIcon,
Actions,
},
directives: {
SafeHtml,
GlModal: GlModalDirective,
},
props: {
data: {
type: Object,
required: true,
},
widgetLabel: {
type: String,
required: true,
},
modalId: {
type: String,
required: false,
default: null,
},
level: {
type: Number,
required: true,
},
},
computed: {
subtext() {
const { subtext } = this.data;
if (subtext) {
if (isArray(subtext)) {
return subtext.map((t) => generateText(t)).join('<br />');
}
return generateText(subtext);
}
return null;
},
},
methods: {
isArray(arr) {
return Array.isArray(arr);
},
onClickedAction(action) {
this.$emit('clickedAction', action);
},
generateText,
},
};
</script>
<template>
<div :class="{ 'gl-pl-6': level === 3 }" class="gl-w-full">
<div v-if="data.header" class="gl-mb-2">
<template v-if="isArray(data.header)">
<component
:is="headerI === 0 ? 'strong' : 'span'"
v-for="(header, headerI) in data.header"
:key="headerI"
v-safe-html="generateText(header)"
class="gl-display-block"
/>
</template>
<strong v-else v-safe-html="generateText(data.header)"></strong>
</div>
<div class="gl-display-flex">
<div v-if="data.icon" class="report-block-child-icon gl-display-flex">
<status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" />
</div>
<div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full">
<div class="gl-display-flex gl-align-items-center">
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
</div>
<div v-if="data.link" class="gl-pr-2">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<div v-if="data.modal" class="gl-pr-2">
<gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick">
{{ data.modal.text }}
</gl-link>
</div>
<div v-if="data.supportingText">
<p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
</div>
<gl-badge
v-if="data.badge"
:variant="data.badge.variant || 'info'"
size="sm"
class="gl-ml-2"
>
{{ data.badge.text }}
</gl-badge>
</div>
<actions
:tertiary-buttons="data.actions"
class="gl-ml-auto gl-pl-3"
@clickedAction="onClickedAction"
/>
</div>
<p v-if="subtext" v-safe-html="subtext" class="gl-m-0 gl-font-sm"></p>
</div>
</div>
<template v-if="data.children && level === 2">
<ul class="gl-m-0 gl-p-0 gl-list-style-none">
<li>
<child-content
v-for="childData in data.children"
:key="childData.id"
:data="childData"
:widget-label="widgetLabel"
:modal-id="modalId"
:level="3"
data-testid="child-content"
@clickedAction="onClickedAction"
/>
</li>
</ul>
</template>
</div>
</template>

View File

@ -1,48 +0,0 @@
import { __ } from '~/locale';
import { registeredExtensions } from './index';
export default {
props: {
mr: {
type: Object,
required: true,
},
},
render(h) {
const { extensions } = registeredExtensions;
return h(
'section',
{
attrs: {
role: 'region',
'aria-label': __('Merge request reports'),
},
},
[
h('div', { attrs: { class: 'mr-widget-section' } }, [
h(
'ul',
{
class: 'gl-p-0 gl-m-0 gl-list-style-none',
},
[
...extensions.map((extension, index) =>
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
h(
{ ...extension },
{
props: {
mr: this.mr,
},
},
),
]),
),
],
),
]),
],
);
},
};

View File

@ -1,64 +0,0 @@
import Vue from 'vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
export const registeredExtensions = Vue.observable({ extensions: [] });
const createCustomOptionsWithFallback = (extension) => (options) => {
return options.reduce((acc, option) => {
acc[option] = extension[option] ?? ExtensionBase[option];
return acc;
}, {});
};
export const registerExtension = (extension) => {
const customOptions = createCustomOptionsWithFallback(extension);
registeredExtensions.extensions.push(
markRaw({
extends: ExtensionBase,
name: extension.name,
props: {
mr: {
type: Object,
required: true,
},
},
// Vue 3 doesn't copy custom component options with Vue.extend
// We have to explicitly fallback to the base component if an option is missing
...customOptions([
'telemetry',
'i18n',
'expandEvent',
'enablePolling',
'enableExpandedPolling',
'modalComponent',
]),
computed: {
...extension.props.reduce(
(acc, propKey) => ({
...acc,
[propKey]() {
return this.mr[propKey];
},
}),
{},
),
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
...acc,
// Making the computed property a method allows us to pass in arguments
// this allows for each computed property to receive some data
[computedKey]() {
return extension.computed[computedKey];
},
}),
{},
),
},
methods: {
...extension.methods,
},
}),
);
};

View File

@ -1,76 +0,0 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants';
export default {
components: {
GlLoadingIcon,
GlIcon,
},
props: {
level: {
type: Number,
required: false,
default: 0,
},
name: {
type: String,
required: false,
default: '',
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
iconName: {
type: String,
required: false,
default: null,
},
size: {
type: Number,
required: false,
default: 12,
},
},
computed: {
iconAriaLabel() {
return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`;
},
},
EXTENSION_ICON_NAMES,
EXTENSION_ICON_CLASS,
};
</script>
<template>
<div
:class="[
$options.EXTENSION_ICON_CLASS[iconName],
{ 'gl-w-6': !isLoading && level === 1 },
{ 'gl-p-2': isLoading || level === 1 },
]"
class="gl-mr-3 gl-p-2"
>
<div
class="gl-rounded-full gl-relative gl-display-flex"
:class="{ 'mr-widget-extension-icon': !isLoading && level === 1 }"
>
<div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
<div class="gl-display-flex gl-m-auto gl-translate-y-n50">
<gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
:size="size"
:aria-label="iconAriaLabel"
:data-testid="`status-${iconName}-icon`"
class="gl-display-block"
/>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,7 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { STATUS_CLOSED, STATUS_MERGED, STATUS_EMPTY } from '~/issues/constants';
import StatusIcon from './extensions/status_icon.vue';
import StatusIcon from './widget/status_icon.vue';
export default {
components: {

View File

@ -1,10 +0,0 @@
// This is here because ee_else_ce requires both ce and ee versions of the
// file to be present.
// TODO: implement me
export default {
name: 'WidgetSecurityReportsCE',
data() {
return {};
},
props: ['securityReportPaths'],
};

View File

@ -1,6 +1,5 @@
<script>
import { isEmpty, clamp } from 'lodash';
import { registeredExtensions } from '~/vue_merge_request_widget/components/extensions';
import SafeHtml from '~/vue_shared/directives/safe_html';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
@ -39,7 +38,6 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue';
import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
import ExtensionsContainer from './components/extensions/container';
import WidgetContainer from './components/widget/app.vue';
import {
STATE_MACHINE,
@ -65,7 +63,6 @@ export default {
},
components: {
Loading,
ExtensionsContainer,
WidgetContainer,
MrWidgetSuggestPipeline: WidgetSuggestPipeline,
MrWidgetPipelineContainer,
@ -247,9 +244,6 @@ export default {
return !this.mr.mergeDetailsCollapsed;
},
hasExtensions() {
return registeredExtensions.extensions.length;
},
mergeBlockedComponentEnabled() {
return (
window.gon?.features?.mergeBlockedComponent &&
@ -544,7 +538,6 @@ export default {
/>
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
<report-widget-container>
<extensions-container v-if="hasExtensions" :mr="mr" />
<widget-container :mr="mr" />
</report-widget-container>
<div class="mr-section-container mr-widget-workflow">

View File

@ -37,7 +37,8 @@ class GroupsFinder < UnionFinder
def execute
# filtered_groups can contain an array of scopes, so these
# are combined into a single query using UNION.
find_union(filtered_groups, Group).with_route.order_id_desc
groups = find_union(filtered_groups, Group)
sort(groups).with_route
end
private
@ -147,6 +148,12 @@ class GroupsFinder < UnionFinder
groups.search(params[:search], include_parents: params[:parent].blank?)
end
def sort(groups)
return groups.order_id_desc unless params[:sort]
groups.sort_by_attribute(params[:sort])
end
def min_access_level?
current_user && params[:min_access_level].present?
end

View File

@ -10,15 +10,22 @@ module Resolvers
required: false,
description: 'Search query for group name or group full path.'
argument :sort, GraphQL::Types::String,
required: false,
description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \
"for example: `id_desc` or `name_asc`",
default_value: 'name_asc'
private
# rubocop: disable CodeReuse/ActiveRecord
def resolve_groups(**args)
GroupsFinder
.new(context[:current_user], args)
.new(context[:current_user], finder_params(args))
.execute
.reorder(name: :asc)
end
# rubocop: enable CodeReuse/ActiveRecord
def finder_params(args)
args
end
end
end

View File

@ -2,27 +2,13 @@
module Resolvers
module Organizations
class GroupsResolver < BaseResolver
class GroupsResolver < Resolvers::GroupsResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include ResolvesGroups
type Types::GroupType.connection_type, null: true
authorize :read_group
argument :search,
GraphQL::Types::String,
required: false,
description: 'Search query for group name or full path.',
alpha: { milestone: '16.4' }
argument :sort,
Types::Organizations::GroupSortEnum,
description: 'Criteria to sort organization groups by.',
required: false,
default_value: { field: 'name', direction: :asc },
alpha: { milestone: '16.4' }
private
alias_method :organization, :object
@ -30,13 +16,13 @@ module Resolvers
def resolve_groups(**args)
return Group.none if Feature.disabled?(:resolve_organization_groups, current_user)
extra_args = { organization: organization, include_ancestors: false, all_available: false }
groups = GroupsFinder.new(current_user, args.merge(extra_args)).execute
super
end
args[:sort] ||= { field: 'name', direction: :asc }
field = args[:sort][:field]
direction = args[:sort][:direction]
groups.sort_by_attribute("#{field}_#{direction}")
def finder_params(args)
extra_args = { organization: organization, include_ancestors: false, all_available: false }
super.merge(extra_args)
end
end
end

View File

@ -2,30 +2,24 @@
module Resolvers
module Organizations
class ProjectsResolver < BaseResolver
class ProjectsResolver < Resolvers::ProjectsResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::ProjectType, null: true
type Types::ProjectType.connection_type, null: true
authorize :read_project
private
alias_method :organization, :object
argument :sort, GraphQL::Types::String,
required: false,
description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \
"for example: `id_desc` or `name_asc`",
alpha: { milestone: '16.9' }
def resolve(**args)
project_finder_params = args.merge(organization: organization)
if %w[path_asc path_desc].include?(project_finder_params[:sort]) &&
def finder_params(args)
if %w[path_asc path_desc].include?(args[:sort]) &&
Feature.disabled?(:project_path_sort, current_user, type: :gitlab_com_derisk)
project_finder_params.delete(:sort)
args.delete(:sort)
end
::ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute
super.merge(organization: organization)
end
end
end

View File

@ -57,6 +57,10 @@ module Types
field :environment_type, GraphQL::Types::String,
description: 'Folder name of the environment.'
field :deployments_display_count, GraphQL::Types::String, null: true,
description: 'Number of deployments in the environment for display. '\
'Returns the precise number up to 999, and "999+" for counts exceeding this limit.'
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,

View File

@ -1,24 +0,0 @@
# frozen_string_literal: true
module Types
module Organizations
class GroupSortEnum < BaseEnum
graphql_name 'OrganizationGroupSort'
description 'Values for sorting organization groups'
sortable_fields = ['ID', 'Name', 'Path', 'Updated at', 'Created at']
sortable_fields.each do |field|
value "#{field.upcase.tr(' ', '_')}_ASC",
value: { field: field.downcase.tr(' ', '_'), direction: :asc },
description: "#{field} in ascending order.",
alpha: { milestone: '16.4' }
value "#{field.upcase.tr(' ', '_')}_DESC",
value: { field: field.downcase.tr(' ', '_'), direction: :desc },
description: "#{field} in descending order.",
alpha: { milestone: '16.4' }
end
end
end
end

View File

@ -9,6 +9,7 @@ module Ci
include Presentable
self.primary_key = :id
self.sequence_name = :ci_job_stages_id_seq
partitionable scope: :pipeline

View File

@ -4,6 +4,7 @@ class SystemNoteMetadata < ApplicationRecord
include Importable
include IgnorableColumns
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/442307
ignore_column :id_convert_to_bigint, remove_with: '16.11', remove_after: '2024-03-15'
# These notes's action text might contain a reference that is external.

View File

@ -24,3 +24,5 @@ class WorkItemPolicy < IssuePolicy
rule { is_member & can?(:read_work_item) }.enable :admin_work_item_link
end
WorkItemPolicy.prepend_mod

View File

@ -3,7 +3,15 @@
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
presents ::Environment, as: :environment
MAX_DEPLOYMENTS_COUNT = 1000
MAX_DISPLAY_COUNT = '999+'
def path
project_environment_path(project, self)
end
def deployments_display_count
count = deployments.limit(MAX_DEPLOYMENTS_COUNT).count
count >= MAX_DEPLOYMENTS_COUNT ? MAX_DISPLAY_COUNT : count.to_s
end
end

View File

@ -58,6 +58,10 @@ class IssuableBaseService < ::BaseContainerService
can?(current_user, ability_name, issuable)
end
def can_set_confidentiality?(issuable)
can?(current_user, :set_confidentiality, issuable)
end
def filter_params(issuable)
unless can_set_issuable_metadata?(issuable)
params.delete(:labels)
@ -78,7 +82,7 @@ class IssuableBaseService < ::BaseContainerService
# confidential attribute is a special type of metadata and needs to be allowed to be set
# by non-members on issues in public projects so that security issues can be reported as confidential.
params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
params.delete(:confidential) unless can_set_confidentiality?(issuable)
filter_contact_params(issuable)
filter_assignees(issuable)
filter_labels

10
db/docs/p_ci_stages.yml Normal file
View File

@ -0,0 +1,10 @@
---
table_name: p_ci_stages
classes:
- Ci::Stage
feature_categories:
- continuous_integration
description: Routing table for ci_stages
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145217
milestone: '16.10'
gitlab_schema: gitlab_ci

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class PartitionCiStagesAddFkToCiPipelines < Gitlab::Database::Migration[2.2]
include Gitlab::Database::PartitioningMigrationHelpers
milestone '16.10'
disable_ddl_transaction!
TABLE_NAME = :ci_stages
PARENT_TABLE_NAME = :p_ci_stages
FIRST_PARTITION = [100, 101]
PARTITION_COLUMN = :partition_id
BUILDS_TABLE = :p_ci_builds
def up
convert_table_to_first_list_partition(
table_name: TABLE_NAME,
partitioning_column: PARTITION_COLUMN,
parent_table_name: PARENT_TABLE_NAME,
initial_partitioning_value: FIRST_PARTITION
)
end
def down
# rubocop:disable Migration/WithLockRetriesDisallowedMethod -- we're calling methods defined here
with_lock_retries(raise_on_exhaustion: true) do
drop_foreign_key
execute(<<~SQL)
ALTER TABLE #{PARENT_TABLE_NAME} DETACH PARTITION #{TABLE_NAME};
ALTER SEQUENCE ci_stages_id_seq OWNED BY #{TABLE_NAME}.id;
SQL
drop_table(PARENT_TABLE_NAME)
end
# rubocop:enable Migration/WithLockRetriesDisallowedMethod
add_routing_table_fk
prepare_constraint_for_list_partitioning(
table_name: TABLE_NAME,
partitioning_column: PARTITION_COLUMN,
parent_table_name: PARENT_TABLE_NAME,
initial_partitioning_value: FIRST_PARTITION
)
end
private
def foreign_key
@foreign_key ||= Gitlab::Database::PostgresForeignKey
.by_constrained_table_name(BUILDS_TABLE)
.by_referenced_table_name(TABLE_NAME)
.first
end
def drop_foreign_key
raise "Expected to find a foreign key between #{BUILDS_TABLE} and #{PARENT_TABLE_NAME}" unless foreign_key.present?
remove_foreign_key_if_exists(BUILDS_TABLE, name: foreign_key.name)
end
def add_routing_table_fk
add_concurrent_partitioned_foreign_key(
BUILDS_TABLE,
TABLE_NAME,
column: [:partition_id, :stage_id],
target_column: [:partition_id, :id],
reverse_lock_order: true,
on_update: :cascade,
on_delete: :cascade,
validate: true,
name: foreign_key.name,
allow_partitioned: true
)
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CleanupBigintConversionForSystemNoteMetadata < Gitlab::Database::Migration[2.2]
enable_lock_retries!
milestone '16.10'
TABLE = :system_note_metadata
def up
cleanup_conversion_of_integer_to_bigint(TABLE, :id)
end
def down
restore_conversion_of_integer_to_bigint(TABLE, :id)
end
end

View File

@ -0,0 +1 @@
ac3a54a2e761f48354d65cc33e7bc448d48d39dad9da29fc65bdd0594e1d7677

View File

@ -0,0 +1 @@
da99fbef61922e9bf9813913717c5b2b0ca1e368096f97723e72e7b48f44ab4f

View File

@ -611,15 +611,6 @@ BEGIN
END;
$$;
CREATE FUNCTION trigger_eaec934fe6b2() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW."id_convert_to_bigint" := NEW."id";
RETURN NEW;
END;
$$;
CREATE FUNCTION trigger_ff16c1fd43ea() RETURNS trigger
LANGUAGE plpgsql
AS $$
@ -6700,7 +6691,7 @@ CREATE SEQUENCE ci_sources_projects_id_seq
ALTER SEQUENCE ci_sources_projects_id_seq OWNED BY ci_sources_projects.id;
CREATE TABLE ci_stages (
CREATE TABLE p_ci_stages (
project_id integer,
created_at timestamp without time zone,
updated_at timestamp without time zone,
@ -6711,9 +6702,9 @@ CREATE TABLE ci_stages (
id bigint NOT NULL,
partition_id bigint NOT NULL,
pipeline_id bigint,
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL)),
CONSTRAINT partitioning_constraint CHECK ((partition_id = ANY (ARRAY[(100)::bigint, (101)::bigint])))
);
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL))
)
PARTITION BY LIST (partition_id);
CREATE SEQUENCE ci_stages_id_seq
START WITH 1
@ -6722,7 +6713,21 @@ CREATE SEQUENCE ci_stages_id_seq
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE ci_stages_id_seq OWNED BY ci_stages.id;
ALTER SEQUENCE ci_stages_id_seq OWNED BY p_ci_stages.id;
CREATE TABLE ci_stages (
project_id integer,
created_at timestamp without time zone,
updated_at timestamp without time zone,
name character varying,
status integer,
lock_version integer DEFAULT 0,
"position" integer,
id bigint DEFAULT nextval('ci_stages_id_seq'::regclass) NOT NULL,
partition_id bigint NOT NULL,
pipeline_id bigint,
CONSTRAINT check_81b431e49b CHECK ((lock_version IS NOT NULL))
);
CREATE TABLE ci_subscriptions_projects (
id bigint NOT NULL,
@ -16129,7 +16134,6 @@ CREATE SEQUENCE system_access_microsoft_graph_access_tokens_id_seq
ALTER SEQUENCE system_access_microsoft_graph_access_tokens_id_seq OWNED BY system_access_microsoft_graph_access_tokens.id;
CREATE TABLE system_note_metadata (
id_convert_to_bigint integer DEFAULT 0 NOT NULL,
commit_count integer,
action character varying,
created_at timestamp without time zone NOT NULL,
@ -18357,6 +18361,8 @@ ALTER TABLE ONLY p_ci_job_artifacts ATTACH PARTITION ci_job_artifacts FOR VALUES
ALTER TABLE ONLY p_ci_pipeline_variables ATTACH PARTITION ci_pipeline_variables FOR VALUES IN ('100', '101');
ALTER TABLE ONLY p_ci_stages ATTACH PARTITION ci_stages FOR VALUES IN ('100', '101');
ALTER TABLE ONLY abuse_events ALTER COLUMN id SET DEFAULT nextval('abuse_events_id_seq'::regclass);
ALTER TABLE ONLY abuse_report_assignees ALTER COLUMN id SET DEFAULT nextval('abuse_report_assignees_id_seq'::regclass);
@ -18629,8 +18635,6 @@ ALTER TABLE ONLY ci_sources_pipelines ALTER COLUMN id SET DEFAULT nextval('ci_so
ALTER TABLE ONLY ci_sources_projects ALTER COLUMN id SET DEFAULT nextval('ci_sources_projects_id_seq'::regclass);
ALTER TABLE ONLY ci_stages ALTER COLUMN id SET DEFAULT nextval('ci_stages_id_seq'::regclass);
ALTER TABLE ONLY ci_subscriptions_projects ALTER COLUMN id SET DEFAULT nextval('ci_subscriptions_projects_id_seq'::regclass);
ALTER TABLE ONLY ci_trigger_requests ALTER COLUMN id SET DEFAULT nextval('ci_trigger_requests_id_seq'::regclass);
@ -19103,6 +19107,8 @@ ALTER TABLE ONLY p_ci_builds_metadata ALTER COLUMN id SET DEFAULT nextval('ci_bu
ALTER TABLE ONLY p_ci_job_annotations ALTER COLUMN id SET DEFAULT nextval('p_ci_job_annotations_id_seq'::regclass);
ALTER TABLE ONLY p_ci_stages ALTER COLUMN id SET DEFAULT nextval('ci_stages_id_seq'::regclass);
ALTER TABLE ONLY packages_build_infos ALTER COLUMN id SET DEFAULT nextval('packages_build_infos_id_seq'::regclass);
ALTER TABLE ONLY packages_composer_cache_files ALTER COLUMN id SET DEFAULT nextval('packages_composer_cache_files_id_seq'::regclass);
@ -20570,6 +20576,9 @@ ALTER TABLE ONLY ci_sources_pipelines
ALTER TABLE ONLY ci_sources_projects
ADD CONSTRAINT ci_sources_projects_pkey PRIMARY KEY (id);
ALTER TABLE ONLY p_ci_stages
ADD CONSTRAINT p_ci_stages_pkey PRIMARY KEY (id, partition_id);
ALTER TABLE ONLY ci_stages
ADD CONSTRAINT ci_stages_pkey PRIMARY KEY (id, partition_id);
@ -24506,14 +24515,24 @@ CREATE INDEX index_ci_sources_projects_on_pipeline_id ON ci_sources_projects USI
CREATE UNIQUE INDEX index_ci_sources_projects_on_source_project_id_and_pipeline_id ON ci_sources_projects USING btree (source_project_id, pipeline_id);
CREATE INDEX p_ci_stages_pipeline_id_idx ON ONLY p_ci_stages USING btree (pipeline_id);
CREATE INDEX index_ci_stages_on_pipeline_id ON ci_stages USING btree (pipeline_id);
CREATE INDEX p_ci_stages_pipeline_id_id_idx ON ONLY p_ci_stages USING btree (pipeline_id, id) WHERE (status = ANY (ARRAY[0, 1, 2, 8, 9, 10]));
CREATE INDEX index_ci_stages_on_pipeline_id_and_id ON ci_stages USING btree (pipeline_id, id) WHERE (status = ANY (ARRAY[0, 1, 2, 8, 9, 10]));
CREATE INDEX p_ci_stages_pipeline_id_position_idx ON ONLY p_ci_stages USING btree (pipeline_id, "position");
CREATE INDEX index_ci_stages_on_pipeline_id_and_position ON ci_stages USING btree (pipeline_id, "position");
CREATE UNIQUE INDEX p_ci_stages_pipeline_id_name_partition_id_idx ON ONLY p_ci_stages USING btree (pipeline_id, name, partition_id);
CREATE UNIQUE INDEX index_ci_stages_on_pipeline_id_name_partition_id_unique ON ci_stages USING btree (pipeline_id, name, partition_id);
CREATE INDEX p_ci_stages_project_id_idx ON ONLY p_ci_stages USING btree (project_id);
CREATE INDEX index_ci_stages_on_project_id ON ci_stages USING btree (project_id);
CREATE INDEX index_ci_subscriptions_projects_author_id ON ci_subscriptions_projects USING btree (author_id);
@ -29054,6 +29073,8 @@ ALTER INDEX p_ci_job_artifacts_pkey ATTACH PARTITION ci_job_artifacts_pkey;
ALTER INDEX p_ci_pipeline_variables_pkey ATTACH PARTITION ci_pipeline_variables_pkey;
ALTER INDEX p_ci_stages_pkey ATTACH PARTITION ci_stages_pkey;
ALTER INDEX p_ci_job_artifacts_job_id_file_type_partition_id_idx ATTACH PARTITION idx_ci_job_artifacts_on_job_id_file_type_and_partition_id_uniq;
ALTER INDEX p_ci_builds_commit_id_bigint_artifacts_expire_at_id_idx ATTACH PARTITION index_357cc39ca4;
@ -29136,6 +29157,16 @@ ALTER INDEX p_ci_job_artifacts_project_id_id_idx1 ATTACH PARTITION index_ci_job_
ALTER INDEX p_ci_job_artifacts_project_id_idx1 ATTACH PARTITION index_ci_job_artifacts_on_project_id_for_security_reports;
ALTER INDEX p_ci_stages_pipeline_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id;
ALTER INDEX p_ci_stages_pipeline_id_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_and_id;
ALTER INDEX p_ci_stages_pipeline_id_position_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_and_position;
ALTER INDEX p_ci_stages_pipeline_id_name_partition_id_idx ATTACH PARTITION index_ci_stages_on_pipeline_id_name_partition_id_unique;
ALTER INDEX p_ci_stages_project_id_idx ATTACH PARTITION index_ci_stages_on_project_id;
ALTER INDEX p_ci_builds_commit_id_bigint_stage_idx_created_at_idx ATTACH PARTITION index_d46de3aa4f;
ALTER INDEX p_ci_builds_commit_id_bigint_type_ref_idx ATTACH PARTITION index_fc42f73fa6;
@ -29204,8 +29235,6 @@ CREATE TRIGGER trigger_catalog_resource_sync_event_on_project_update AFTER UPDAT
CREATE TRIGGER trigger_delete_project_namespace_on_project_delete AFTER DELETE ON projects FOR EACH ROW WHEN ((old.project_namespace_id IS NOT NULL)) EXECUTE FUNCTION delete_associated_project_namespace();
CREATE TRIGGER trigger_eaec934fe6b2 BEFORE INSERT OR UPDATE ON system_note_metadata FOR EACH ROW EXECUTE FUNCTION trigger_eaec934fe6b2();
CREATE TRIGGER trigger_ff16c1fd43ea BEFORE INSERT OR UPDATE ON geo_event_log FOR EACH ROW EXECUTE FUNCTION trigger_ff16c1fd43ea();
CREATE TRIGGER trigger_has_external_issue_tracker_on_delete AFTER DELETE ON integrations FOR EACH ROW WHEN ((((old.category)::text = 'issue_tracker'::text) AND (old.active = true) AND (old.project_id IS NOT NULL))) EXECUTE FUNCTION set_has_external_issue_tracker();
@ -30415,7 +30444,7 @@ ALTER TABLE ONLY protected_tag_create_access_levels
ALTER TABLE ONLY application_settings
ADD CONSTRAINT fk_f9867b3540 FOREIGN KEY (web_ide_oauth_application_id) REFERENCES oauth_applications(id) ON DELETE SET NULL;
ALTER TABLE ONLY ci_stages
ALTER TABLE p_ci_stages
ADD CONSTRAINT fk_fb57e6cc56 FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_group_authorizations

View File

@ -329,7 +329,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
sudo -i
```
1. Stop application server and Sidekiq
1. Stop application server and Sidekiq:
```shell
gitlab-ctl stop puma

View File

@ -138,7 +138,7 @@ To disable writes to the `authorized_keys` file:
Again, confirm that SSH is working by removing your user's SSH key in the UI,
adding a new one, and attempting to pull a repository.
Then you can backup and delete your `authorized_keys` file for best performance.
Then you can back up and delete your `authorized_keys` file for best performance.
The current users' keys are already present in the database, so there is no need for migration
or for users to re-add their keys.

View File

@ -413,6 +413,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="querygroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. |
| <a id="querygroupssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
### `Query.instanceExternalAuditEventDestinations`
@ -18740,6 +18741,7 @@ Describes where code is deployed for a project.
| <a id="environmentclusteragent"></a>`clusterAgent` | [`ClusterAgent`](#clusteragent) | Cluster agent of the environment. |
| <a id="environmentcreatedat"></a>`createdAt` | [`Time`](#time) | When the environment was created. |
| <a id="environmentdeployfreezes"></a>`deployFreezes` | [`[CiFreezePeriod!]`](#cifreezeperiod) | Deployment freeze periods of the environment. |
| <a id="environmentdeploymentsdisplaycount"></a>`deploymentsDisplayCount` | [`String`](#string) | Number of deployments in the environment for display. Returns the precise number up to 999, and "999+" for counts exceeding this limit. |
| <a id="environmentenvironmenttype"></a>`environmentType` | [`String`](#string) | Folder name of the environment. |
| <a id="environmentexternalurl"></a>`externalUrl` | [`String`](#string) | External URL of the environment. |
| <a id="environmentfluxresourcepath"></a>`fluxResourcePath` | [`String`](#string) | Flux resource path of the environment. |
@ -24218,8 +24220,8 @@ four standard [pagination arguments](#connection-pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="organizationgroupssearch"></a>`search` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.4. **Status**: Experiment. Search query for group name or full path. |
| <a id="organizationgroupssort"></a>`sort` **{warning-solid}** | [`OrganizationGroupSort`](#organizationgroupsort) | **Introduced** in 16.4. **Status**: Experiment. Criteria to sort organization groups by. |
| <a id="organizationgroupssearch"></a>`search` | [`String`](#string) | Search query for group name or group full path. |
| <a id="organizationgroupssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
##### `Organization.projects`
@ -24239,7 +24241,15 @@ four standard [pagination arguments](#connection-pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="organizationprojectssort"></a>`sort` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.9. **Status**: Experiment. Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
| <a id="organizationprojectsfullpaths"></a>`fullPaths` | [`[String!]`](#string) | Filter projects by full paths. You cannot provide more than 50 full paths. |
| <a id="organizationprojectsids"></a>`ids` | [`[ID!]`](#id) | Filter projects by IDs. |
| <a id="organizationprojectsmembership"></a>`membership` | [`Boolean`](#boolean) | Return only projects that the current user is a member of. |
| <a id="organizationprojectssearch"></a>`search` | [`String`](#string) | Search query, which can be for the project name, a path, or a description. |
| <a id="organizationprojectssearchnamespaces"></a>`searchNamespaces` | [`Boolean`](#boolean) | Include namespace in project search. |
| <a id="organizationprojectssort"></a>`sort` | [`String`](#string) | Sort order of results. Format: `<field_name>_<sort_direction>`, for example: `id_desc` or `name_asc`. |
| <a id="organizationprojectstopics"></a>`topics` | [`[String!]`](#string) | Filter projects by topics. |
| <a id="organizationprojectswithissuesenabled"></a>`withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. |
| <a id="organizationprojectswithmergerequestsenabled"></a>`withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. |
### `OrganizationStateCounts`
@ -32130,23 +32140,6 @@ Rotation length unit of an on-call rotation.
| <a id="oncallrotationunitenumhours"></a>`HOURS` | Hours. |
| <a id="oncallrotationunitenumweeks"></a>`WEEKS` | Weeks. |
### `OrganizationGroupSort`
Values for sorting organization groups.
| Value | Description |
| ----- | ----------- |
| <a id="organizationgroupsortcreated_at_asc"></a>`CREATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Created at in ascending order. |
| <a id="organizationgroupsortcreated_at_desc"></a>`CREATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Created at in descending order. |
| <a id="organizationgroupsortid_asc"></a>`ID_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. ID in ascending order. |
| <a id="organizationgroupsortid_desc"></a>`ID_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. ID in descending order. |
| <a id="organizationgroupsortname_asc"></a>`NAME_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Name in ascending order. |
| <a id="organizationgroupsortname_desc"></a>`NAME_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Name in descending order. |
| <a id="organizationgroupsortpath_asc"></a>`PATH_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Path in ascending order. |
| <a id="organizationgroupsortpath_desc"></a>`PATH_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Path in descending order. |
| <a id="organizationgroupsortupdated_at_asc"></a>`UPDATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Updated at in ascending order. |
| <a id="organizationgroupsortupdated_at_desc"></a>`UPDATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. **Status**: Experiment. Updated at in descending order. |
### `OrganizationSort`
Values for sorting organizations.

View File

@ -117,9 +117,11 @@ into the partitioned copy.
Continuing the above example, the migration would look like:
```ruby
class BackfillPartitionMergeRequestDiffCommits < Gitlab::Database::Migration[2.1]
class BackfillPartitionMergeRequestDiffCommits < Gitlab::Database::Migration[2.2]
include Gitlab::Database::PartitioningMigrationHelpers
milestone '16.10'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main

View File

@ -0,0 +1,153 @@
---
stage: Create
group: Source Code
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Workhorse handlers
Long HTTP requests are hard to handle efficiently in Rails.
The requests are either memory-inefficient (file uploads) or impossible at all due to shorter timeouts
(for example, Puma server has 60-second timeout).
Workhorse can efficiently handle a large number of long HTTP requests.
Workhorse acts as a proxy that intercepts all HTTP requests and either propagates them without
changing or handles them itself by performing additional logic.
## Injectors
```mermaid
sequenceDiagram
participant Client
participant Workhorse
participant Rails
Client->>+Workhorse: Request
Workhorse->>+Rails: Propagate the request as-is
Rails-->>-Workhorse: Respond with a special header that contains instructions for proceeding with the request
Workhorse-->>Client: Response
```
### Example: Send a Git blob
```mermaid
sequenceDiagram
participant Client
participant Workhorse
participant Rails
participant Gitaly
Client->>+Workhorse: HTTP Request for a blob
Workhorse->>+Rails: Propagate the request as-is
Rails-->>-Workhorse: Respond with a git-blob:{encoded_data} header
Workhorse->>+Gitaly: BlobService.GetBlob gRPC request
Gitaly-->>-Workhorse: BlobService.GetBlob gRPC request
Workhorse-->>Client: Stream the data
```
### How GitLab Rails processes the request
- [`send_git_blob`](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/lib/api/files.rb#L161)
- [Send a header with a particular information](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/lib/gitlab/workhorse.rb#L49-63)
### How Workhorse processes the header
- [Specify a list of injectors](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/upstream/routes.go#L179)
- [Iterate over injectors to find a match](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/senddata/senddata.go#L88)
- [Process a particular request](https://gitlab.com/gitlab-org/gitlab/blob/8ba71b1f2feec64aeec52ccac4a1e585ba8052d9/workhorse/internal/git/blob.go#L23)
#### Example: Send a file
```mermaid
sequenceDiagram
participant Client
participant Workhorse
participant Rails
participant Object Storage
Client->>+Workhorse: HTTP Request for a file
Workhorse->>+Rails: Propagate the request as-is
Rails-->>-Workhorse: Respond with a send-url:{encoded_data} header
Workhorse->>+Object Storage: Request for a file
Object Storage-->>-Workhorse: Stream the data
Workhorse-->>Client: Stream the data
```
## Pre-authorized requests
```mermaid
sequenceDiagram
participant Client
participant Workhorse
participant Rails
participant Object Storage
Client->>+Workhorse: PUT /artifacts/uploads
Note right of Rails: Append `/authorize` to the original URL and call Rails for an Auth check
Workhorse->>+Rails: GET /artifacts/uploads/authorize
Rails-->>-Workhorse: Authorized successfully
Client->>+Workhorse: Stream the file content
Workhorse->>+Object Storage: Upload the file
Object Storage-->>-Workhorse: Success
Workhorse->>+Rails: Finalize the request
Note right of Rails: Workhorse calls the original URL to create a database record
Rails-->>-Workhorse: Finalized successfully
Workhorse-->>Client: Uploaded successfully
```
## Git over HTTP(S)
Workhorse accelerates Git over HTTP(S) by handling [Git HTTP protocol](https://www.git-scm.com/docs/http-protocol) requests. For example, Git push/pull may require serving large amounts of data and in order to avoid transferring it through GitLab Rails, Workhorse only performs authorization checks against GitLab Rails, then performs Gitaly gRPC request directly and streams the data from Gitaly to the Git client.
### Git pull
```mermaid
sequenceDiagram
participant Git on client
participant Workhorse
participant Rails
participant Gitaly
Note left of Git on client: git clone/fetch
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-upload-pack
Workhorse->>+Rails: GET Repositories::GitHttpController#info_refs
Note right of Rails: Access check/Log activity
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
Workhorse->>+Gitaly: SmartHTTPService.InfoRefsUploadPack gRPC request
Gitaly -->>-Workhorse: SmartHTTPService.InfoRefsUploadPack gRPC response
Workhorse-->>-Git on client: send info-refs response
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-upload-pack
Workhorse->>+Rails: GET Repositories::GitHttpController#git_receive_pack
Note right of Rails: Access check/Update statistics
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
Workhorse->>+Gitaly: SmartHTTPService.PostUploadPackWithSidechannel gRPC request
Gitaly -->>-Workhorse: SmartHTTPService.PostUploadPackWithSidechannel gRPC response
Workhorse-->>-Git on client: send response
```
### Git push
```mermaid
sequenceDiagram
participant Git on client
participant Workhorse
participant Rails
participant Gitaly
Note left of Git on client: git push
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-receive-pack
Workhorse->>+Rails: GET Repositories::GitHttpController#info_refs
Note right of Rails: Access check/Log activity
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
Workhorse->>+Gitaly: SmartHTTPService.InfoRefsReceivePack gRPC request
Gitaly -->>-Workhorse: SmartHTTPService.InfoRefsReceivePack gRPC response
Workhorse-->>-Git on client: send info-refs response
Git on client->>+Workhorse: GET /foo/bar.git/info/refs/?service=git-receive-pack
Workhorse->>+Rails: GET Repositories::GitHttpController#git_receive_pack
Note right of Rails: Access check/Update statistics
Rails-->>Workhorse: 200 OK, Gitlab::Workhorse.git_http_ok
Workhorse->>+Gitaly: SmartHTTPService.PostReceivePackWithSidechannel gRPC request
Gitaly -->>-Workhorse: SmartHTTPService.PostReceivePackWithSidechannel gRPC response
Workhorse-->>-Git on client: send response
```

View File

@ -217,6 +217,10 @@ To rectify the following error, specify the deprecated DSN in **Sentry.io > Proj
ERROR: Sentry failure builds=0 error=raven: dsn missing private key
```
## Data retention
GitLab has a retention limit of 90 days for all errors.
## Troubleshooting
When working with Error Tracking, you might encounter the following issues.
@ -228,7 +232,3 @@ you might see an error when you try to [enable Sentry integration for a project]
The resulting request to `/project/path/-/error_tracking/projects.json?api_host=https:%2F%2Fsentry.example.com%2F&token=<token>` returns a 404 status.
To fix this issue, enable the Monitor feature for the project.
## Data Retention
GitLab has a retention limit of 90 days for all errors.

View File

@ -94,6 +94,6 @@ After the limit is exceeded, a `429 Too Many Requests` response is returned.
To request a limit increase to 104,8576 bytes per minute, contact GitLab support.
## Data Retention
## Data retention
GitLab has a retention limit of 30 days for all traces.

View File

@ -33,7 +33,6 @@ Some features are still in development. View details about [support for each sta
| Helps you understand code by explaining it in English language. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=1izKaLmmaCA) | [Code explanation](#explain-code-in-the-web-ui-with-code-explanation) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
| Assists you in determining the root cause for a pipeline failure and failed CI/CD build. | [Root cause analysis](#root-cause-analysis) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
| Assists you with predicting productivity metrics and identifying anomalies across your software development lifecycle. | [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | **Tier:** Ultimate <br>**Offering:** SaaS, self-managed <br>**Status:** Experiment |
| Processes and responds to your questions about your application's usage data. | [Product Analytics](product_analytics/index.md) | **Tier:** Ultimate <br>**Offering:** SaaS <br>**Status:** Experiment |
## Enable AI/ML features

View File

@ -1933,6 +1933,18 @@ msgstr ""
msgid "AIAgent|AI Agent: %{agentId}"
msgstr ""
msgid "AIAgent|Agent"
msgstr ""
msgid "AIAgent|Ask your agent"
msgstr ""
msgid "AIAgent|Try out your agent"
msgstr ""
msgid "AIAgent|Your agent's system prompt will be applied to the chat input."
msgstr ""
msgid "AIPoweredSM|AI-powered features"
msgstr ""
@ -49007,7 +49019,7 @@ msgstr ""
msgid "Sync LDAP"
msgstr ""
msgid "Sync now"
msgid "Sync changes"
msgstr ""
msgid "Synced"
@ -58523,12 +58535,6 @@ msgstr ""
msgid "ciReport|%{scanner} detected %{number} new potential %{vulnStr}"
msgstr ""
msgid "ciReport|%{scanner} detected %{strong_start}%{number}%{strong_end} new potential %{vulnStr}"
msgstr ""
msgid "ciReport|%{scanner} detected no new %{vulnStr}"
msgstr ""
msgid "ciReport|%{scanner} detected no new potential vulnerabilities"
msgstr ""
@ -58758,24 +58764,9 @@ msgstr ""
msgid "ciReport|TTFB P95"
msgstr ""
msgid "ciReport|There was an error creating the issue. Please try again."
msgstr ""
msgid "ciReport|There was an error creating the merge request. Please try again."
msgstr ""
msgid "ciReport|There was an error dismissing the vulnerability. Please try again."
msgstr ""
msgid "ciReport|There was an error dismissing the vulnerability: %{error}"
msgstr ""
msgid "ciReport|There was an error reverting the dismissal. Please try again."
msgstr ""
msgid "ciReport|There was an error reverting the dismissal: %{error}"
msgstr ""
msgid "ciReport|This report contains all Code Quality issues in the source branch."
msgstr ""
@ -59617,9 +59608,6 @@ msgstr ""
msgid "mrWidget|Go to first unresolved thread"
msgstr ""
msgid "mrWidget|Hide %{widget} details"
msgstr ""
msgid "mrWidget|If the %{type} branch exists in your local repository, you can merge this merge request manually using the command line."
msgstr ""
@ -59706,9 +59694,6 @@ msgstr ""
msgid "mrWidget|Set by %{merge_author} to start a merge train when the pipeline succeeds"
msgstr ""
msgid "mrWidget|Show %{widget} details"
msgstr ""
msgid "mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist."
msgstr ""

View File

@ -105,6 +105,7 @@ RSpec.describe 'Database schema', feature_category: :database do
p_ci_finished_build_ch_sync_events: %w[build_id],
p_ci_job_artifacts: %w[partition_id project_id job_id],
p_ci_pipeline_variables: %w[partition_id],
p_ci_stages: %w[partition_id project_id pipeline_id],
project_build_artifacts_size_refreshes: %w[last_job_artifact_id],
project_data_transfers: %w[project_id namespace_id],
project_error_tracking_settings: %w[sentry_project_id],

View File

@ -4,13 +4,12 @@ require 'spec_helper'
RSpec.describe GroupsFinder, feature_category: :groups_and_projects do
include AdminModeHelper
using RSpec::Parameterized::TableSyntax
describe '#execute' do
let(:user) { create(:user) }
describe 'root level groups' do
using RSpec::Parameterized::TableSyntax
where(:user_type, :params, :results) do
nil | { all_available: true } | %i[public_group user_public_group]
nil | { all_available: false } | %i[public_group user_public_group]
@ -407,5 +406,27 @@ RSpec.describe GroupsFinder, feature_category: :groups_and_projects do
end
end
end
describe 'group sorting' do
let_it_be(:all_groups) { create_list(:group, 3, :public) }
subject(:result) { described_class.new(nil, params).execute.to_a }
where(:field, :direction, :sorted_groups) do
'id' | 'asc' | lazy { all_groups.sort_by(&:id) }
'id' | 'desc' | lazy { all_groups.sort_by(&:id).reverse }
'name' | 'asc' | lazy { all_groups.sort_by(&:name) }
'name' | 'desc' | lazy { all_groups.sort_by(&:name).reverse }
'path' | 'asc' | lazy { all_groups.sort_by(&:path) }
'path' | 'desc' | lazy { all_groups.sort_by(&:path).reverse }
end
with_them do
let(:sort) { "#{field}_#{direction}" }
let(:params) { { sort: sort } }
it { is_expected.to eq(sorted_groups) }
end
end
end
end

View File

@ -20,6 +20,9 @@
<div class="form-group">
<textarea required title="Textarea is required">Textarea</textarea>
</div>
<div class="form-group">
<input type="text" title="xss:&lt;script&gt;alert(0)&lt;/script&gt;"></input>
</div>
<div class="form-group"></div>
<input class="submit" type="submit">Submit</input>
</form>

View File

@ -28,7 +28,7 @@ describe('GL Style Field Errors', () => {
expect(testContext.fieldErrors).toBeDefined();
const { inputs } = testContext.fieldErrors.state;
expect(inputs.length).toBe(5);
expect(inputs.length).toBe(6);
});
it('should ignore elements with custom error handling', () => {
@ -125,4 +125,15 @@ describe('GL Style Field Errors', () => {
expect(noTitleErrorElem.text()).toBe('This field is required.');
expect(hasTitleErrorElem.text()).toBe('Please provide a valid email address.');
});
it('sanitizes error messages before appending them to DOM', () => {
testContext.$form.submit();
const trackedInputs = testContext.fieldErrors.state.inputs;
const xssInput = trackedInputs[5];
const xssErrorElem = xssInput.inputElement.siblings('.gl-field-error');
expect(xssErrorElem.html()).toBe('xss:');
});
});

View File

@ -1,12 +1,15 @@
import { GlAlert } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Autosize from 'autosize';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import {
extendedWrapper,
mountExtended,
shallowMountExtended,
} from 'helpers/vue_test_utils_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
@ -48,7 +51,7 @@ describe('issue_comment_form component', () => {
const findCommentButton = () => findCommentTypeDropdown().find('button');
const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers;
const createStore = ({ actions = {}, state = {} } = {}) => {
const createStore = ({ actions = { saveNote: jest.fn() }, state = {} } = {}) => {
const baseModule = notesModule();
return new Vuex.Store({
@ -94,38 +97,27 @@ describe('issue_comment_form component', () => {
notesData = notesDataMock,
userData = userDataMock,
features = {},
mountFunction = shallowMount,
mountFunction = shallowMountExtended,
store = createStore(),
} = {}) => {
store.dispatch('setNoteableData', noteableData);
store.dispatch('setNotesData', notesData);
store.dispatch('setUserData', userData);
wrapper = extendedWrapper(
mountFunction(CommentForm, {
propsData: {
noteableType,
},
data() {
return {
...initialData,
};
},
store,
provide: {
glFeatures: features,
},
mocks: {
$apollo: {
queries: {
currentUser: {
loading: false,
},
},
},
},
}),
);
wrapper = mountFunction(CommentForm, {
propsData: {
noteableType,
},
data() {
return {
...initialData,
};
},
store,
provide: {
glFeatures: features,
},
});
};
beforeEach(() => {
@ -142,28 +134,20 @@ describe('issue_comment_form component', () => {
const note = 'hello world';
it('should request to save note when note is entered', async () => {
const saveNoteSpy = jest.fn();
const store = createStore({
actions: {
saveNote: saveNoteSpy,
},
});
mountComponent({ mountFunction: mount, initialData: { note }, store });
const store = createStore();
jest.spyOn(store, 'dispatch');
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
expect(findCloseReopenButton().props('disabled')).toBe(false);
expect(findMarkdownEditor().props('value')).toBe(note);
await findCloseReopenButton().trigger('click');
expect(findCloseReopenButton().props('disabled')).toBe(true);
expect(findMarkdownEditor().props('value')).toBe('');
expect(saveNoteSpy).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenLastCalledWith('saveNote', expect.objectContaining({}));
});
it('tracks event', async () => {
const store = createStore({
actions: {
saveNote: jest.fn().mockResolvedValue(),
},
});
mountComponent({ mountFunction: mount, initialData: { note }, store });
const store = createStore();
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
await findCloseReopenButton().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'save_markdown', {
label: 'markdown_editor',
@ -172,12 +156,12 @@ describe('issue_comment_form component', () => {
});
it('does not report errors in the UI when the save succeeds', async () => {
const store = createStore({
actions: {
saveNote: jest.fn().mockResolvedValue(),
},
const store = createStore();
mountComponent({
mountFunction: mountExtended,
initialData: { note: '/label ~sdfghj' },
store,
});
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
await findCommentButton().trigger('click');
// findErrorAlerts().exists returns false if *any* wrapper is empty,
// not necessarily that there aren't any at all.
@ -202,7 +186,11 @@ describe('issue_comment_form component', () => {
}),
},
});
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
mountComponent({
mountFunction: mountExtended,
initialData: { note: '/label ~sdfghj' },
store,
});
await findCommentButton().trigger('click');
await waitForPromises();
const errorAlerts = findErrorAlerts();
@ -228,7 +216,11 @@ describe('issue_comment_form component', () => {
},
});
mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' }, store });
mountComponent({
mountFunction: mountExtended,
initialData: { note: 'invalid note' },
store,
});
findCommentButton().trigger('click');
});
@ -256,7 +248,11 @@ describe('issue_comment_form component', () => {
}),
},
});
mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' }, store });
mountComponent({
mountFunction: mountExtended,
initialData: { note: '/label ~sdfghj' },
store,
});
await findCommentButton().trigger('click');
await waitForPromises();
@ -278,7 +274,7 @@ describe('issue_comment_form component', () => {
});
it('should toggle issue state when no note', async () => {
mountComponent({ mountFunction: mount });
mountComponent({ mountFunction: mountExtended });
jest.spyOn(eventHub, '$emit');
expect(eventHub.$emit).not.toHaveBeenCalledWith('toggle.issuable.state');
await findCloseReopenButton().trigger('click');
@ -286,28 +282,27 @@ describe('issue_comment_form component', () => {
});
it('should disable action button while submitting', async () => {
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
const saveNotePromise = Promise.resolve();
jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise);
const store = createStore({
actions: {
saveNote: jest.fn().mockReturnValue(),
},
});
mountComponent({
mountFunction: mountExtended,
initialData: { note: 'hello world' },
store,
});
const actionButton = findCloseReopenButton();
await actionButton.trigger('click');
expect(actionButton.props('disabled')).toBe(true);
await saveNotePromise;
await waitForPromises();
await nextTick();
expect(actionButton.props('disabled')).toBe(false);
});
});
it('shows content editor switcher', () => {
mountComponent({ mountFunction: mount });
mountComponent({ mountFunction: mountExtended });
expect(wrapper.text()).toContain('Switch to rich text editing');
});
@ -327,26 +322,21 @@ describe('issue_comment_form component', () => {
);
it('should make textarea disabled while requesting', async () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
mountComponent({ mountFunction: mountExtended });
findMarkdownEditor().vm.$emit('input', 'hello world');
await nextTick();
await findCommentButton().trigger('click');
expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBeDefined();
});
it('should support quick actions', () => {
mountComponent({ mountFunction: mount });
mountComponent({ mountFunction: mountExtended });
expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true);
});
it('should link to markdown docs', () => {
mountComponent({ mountFunction: mount });
mountComponent({ mountFunction: mountExtended });
const { markdownDocsPath } = notesDataMock;
@ -357,7 +347,11 @@ describe('issue_comment_form component', () => {
const store = createStore();
store.registerModule('batchComments', batchComments());
store.state.batchComments.drafts = [{ note: 'A' }];
await mountComponent({ mountFunction: mount, initialData: { note: 'foo' }, store });
await mountComponent({
mountFunction: mountExtended,
initialData: { note: 'foo' },
store,
});
await findAddCommentNowButton().trigger('click');
await waitForPromises();
expect(Autosize.update).toHaveBeenCalled();
@ -365,33 +359,51 @@ describe('issue_comment_form component', () => {
});
describe('edit mode', () => {
it('should enter edit mode when arrow up is pressed', () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
findMarkdownEditorTextarea().trigger('keydown.up');
expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
it('should enter edit mode when arrow up is pressed', async () => {
const noteId = 2;
const store = createStore({
state: {
discussions: [{ notes: [{ id: noteId, author: userDataMock }] }],
},
});
mountComponent({ mountFunction: mountExtended, store });
jest.spyOn(eventHub, '$emit');
await findMarkdownEditorTextarea().trigger('keydown.up');
expect(eventHub.$emit).toHaveBeenCalledWith('enterEditMode', { noteId });
});
describe('event enter', () => {
describe('when no draft exists', () => {
it('should save note when cmd+enter is pressed', () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'handleSave');
const store = createStore({ actions: {} });
findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
it('should save note when cmd+enter is pressed', async () => {
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
jest.spyOn(axios, 'post');
await findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
expect(axios.post).toHaveBeenCalledWith(noteableDataMock.create_note_path, {
merge_request_diff_head_sha: undefined,
note: {
internal: false,
note: 'a',
noteable_id: noteableDataMock.id,
noteable_type: 'Issue',
},
});
});
it('should save note when ctrl+enter is pressed', () => {
mountComponent({ mountFunction: mount });
jest.spyOn(wrapper.vm, 'handleSave');
findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
it('should save note when ctrl+enter is pressed', async () => {
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
jest.spyOn(axios, 'post');
await findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
expect(axios.post).toHaveBeenCalledWith(noteableDataMock.create_note_path, {
merge_request_diff_head_sha: undefined,
note: {
internal: false,
note: 'a',
noteable_id: noteableDataMock.id,
noteable_type: 'Issue',
},
});
});
});
@ -412,7 +424,7 @@ describe('issue_comment_form component', () => {
const note = 'some note text which enables actually adding a draft note';
jest.spyOn(eventHub, '$emit');
mountComponent({ mountFunction: mount, initialData: { note }, store });
mountComponent({ mountFunction: mountExtended, initialData: { note }, store });
findAddToReviewButton().trigger('click');
@ -422,17 +434,41 @@ describe('issue_comment_form component', () => {
});
it('should save note draft when cmd+enter is pressed', async () => {
mountComponent({ mountFunction: mount, store });
jest.spyOn(wrapper.vm, 'handleSaveDraft');
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
jest.spyOn(store, 'dispatch').mockResolvedValue();
await findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
data: {
merge_request_diff_head_sha: undefined,
note: {
internal: false,
note: 'a',
noteable_id: noteableDataMock.id,
noteable_type: 'Issue',
},
},
endpoint: notesDataMock.draftsPath,
isDraft: true,
});
});
it('should save note draft when ctrl+enter is pressed', async () => {
mountComponent({ mountFunction: mount, store });
jest.spyOn(wrapper.vm, 'handleSaveDraft');
mountComponent({ mountFunction: mountExtended, initialData: { note: 'a' }, store });
jest.spyOn(store, 'dispatch').mockResolvedValue();
await findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
data: {
merge_request_diff_head_sha: undefined,
note: {
internal: false,
note: 'a',
noteable_id: noteableDataMock.id,
noteable_type: 'Issue',
},
},
endpoint: notesDataMock.draftsPath,
isDraft: true,
});
});
});
});
@ -452,7 +488,7 @@ describe('issue_comment_form component', () => {
${true} | ${'Add internal note'}
`('renders comment button with text "$buttonText"', ({ noteIsInternal, buttonText }) => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
noteableData: createNotableDataMock({ confidential: noteIsInternal }),
initialData: { noteIsInternal },
});
@ -491,7 +527,7 @@ describe('issue_comment_form component', () => {
it('should show a loading spinner', async () => {
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
mountFunction: mount,
mountFunction: mountExtended,
});
await findCloseReopenButton().trigger('click');
@ -503,7 +539,7 @@ describe('issue_comment_form component', () => {
describe('when toggling state', () => {
describe('when issue', () => {
it('emits event to toggle state', () => {
mountComponent({ mountFunction: mount });
mountComponent({ mountFunction: mountExtended });
jest.spyOn(eventHub, '$emit');
@ -524,7 +560,7 @@ describe('issue_comment_form component', () => {
mountComponent({
noteableType,
noteableData: { ...noteableDataMock, state: STATUS_OPEN },
mountFunction: mount,
mountFunction: mountExtended,
});
expect(axios.put).not.toHaveBeenCalledWith();
findCloseReopenButton().trigger('click');
@ -536,7 +572,7 @@ describe('issue_comment_form component', () => {
mountComponent({
noteableType,
noteableData: { ...noteableDataMock, state: STATUS_OPEN },
mountFunction: mount,
mountFunction: mountExtended,
});
await findCloseReopenButton().trigger('click');
await nextTick();
@ -553,7 +589,7 @@ describe('issue_comment_form component', () => {
mountComponent({
noteableType,
noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
mountFunction: mount,
mountFunction: mountExtended,
});
expect(findCloseReopenButton().text()).toBe(`Reopen ${type}`);
@ -568,7 +604,7 @@ describe('issue_comment_form component', () => {
mountComponent({
noteableType,
noteableData: { ...noteableDataMock, state: STATUS_CLOSED },
mountFunction: mount,
mountFunction: mountExtended,
});
await findCloseReopenButton().trigger('click');
await nextTick();
@ -584,7 +620,7 @@ describe('issue_comment_form component', () => {
jest.spyOn(axios, 'put').mockResolvedValue({ data: {} });
mountComponent({
noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE,
mountFunction: mount,
mountFunction: mountExtended,
});
await findCloseReopenButton().trigger('click');
await waitForPromises();
@ -598,7 +634,7 @@ describe('issue_comment_form component', () => {
describe('confidential notes checkbox', () => {
it('should render checkbox as unchecked by default', () => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCanUpdateIssuable },
});
@ -610,7 +646,7 @@ describe('issue_comment_form component', () => {
it('should not render checkbox if user is not at least a reporter', () => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCannotCreateConfidentialNote },
});
@ -628,7 +664,7 @@ describe('issue_comment_form component', () => {
'should $message checkbox when noteableType is $noteableType',
({ noteableType, rendered }) => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
noteableType,
initialData: { note: 'internal note' },
noteableData: { ...notableDataMockCanUpdateIssuable, noteableType },
@ -644,14 +680,16 @@ describe('issue_comment_form component', () => {
${false}
`('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => {
it(`sets \`internal\` to \`${shouldCheckboxBeChecked}\``, async () => {
const store = createStore();
const note = 'internal note';
mountComponent({
mountFunction: mount,
initialData: { note: 'internal note' },
mountFunction: mountExtended,
initialData: { note },
noteableData: { ...notableDataMockCanUpdateIssuable },
store,
});
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({});
jest.spyOn(store, 'dispatch');
const checkbox = findConfidentialNoteCheckbox();
// check checkbox
@ -662,15 +700,26 @@ describe('issue_comment_form component', () => {
// submit comment
findCommentButton().trigger('click');
const [providedData] = wrapper.vm.saveNote.mock.calls[0];
expect(providedData.data.note.internal).toBe(shouldCheckboxBeChecked);
expect(store.dispatch).toHaveBeenCalledWith('saveNote', {
data: {
merge_request_diff_head_sha: undefined,
note: {
internal: shouldCheckboxBeChecked,
note,
noteable_id: noteableDataMock.id,
noteable_type: 'Issue',
},
},
endpoint: noteableDataMock.create_note_path,
isDraft: false,
});
});
});
describe('when user cannot update issuable', () => {
it('should not render checkbox', () => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
noteableData: { ...notableDataMockCannotUpdateIssuable },
});
@ -683,37 +732,38 @@ describe('issue_comment_form component', () => {
describe('check sensitive tokens', () => {
const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
const nonSensitiveMessage = 'text';
const store = createStore();
it('should not save note when it contains sensitive token', () => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
initialData: { note: sensitiveMessage },
store,
});
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
jest.spyOn(store, 'dispatch');
findCommentButton().trigger('click');
expect(wrapper.vm.saveNote).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('should save note it does not contain sensitive token', () => {
it('should save note it does not contain sensitive token', async () => {
mountComponent({
mountFunction: mount,
mountFunction: mountExtended,
initialData: { note: nonSensitiveMessage },
store,
});
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
findCommentButton().trigger('click');
expect(wrapper.vm.saveNote).toHaveBeenCalled();
jest.spyOn(store, 'dispatch');
await findCommentButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('saveNote', expect.objectContaining({}));
});
});
describe('user is not logged in', () => {
beforeEach(() => {
mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount });
mountComponent({
userData: null,
noteableData: loggedOutnoteableData,
mountFunction: mountExtended,
});
});
it('should render signed out widget', () => {
@ -754,7 +804,11 @@ describe('issue_comment_form component', () => {
});
it('clicking `add to review`, should call draft endpoint, set `isDraft` true', async () => {
mountComponent({ mountFunction: mount, initialData: { note: 'a draft note' }, store });
mountComponent({
mountFunction: mountExtended,
initialData: { note: 'a draft note' },
store,
});
jest.spyOn(store, 'dispatch').mockResolvedValue();
await findAddToReviewButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith(
@ -767,7 +821,11 @@ describe('issue_comment_form component', () => {
});
it('clicking `add comment now`, should call note endpoint, set `isDraft` false', async () => {
await mountComponent({ mountFunction: mount, initialData: { note: 'a comment' }, store });
await mountComponent({
mountFunction: mountExtended,
initialData: { note: 'a comment' },
store,
});
jest.spyOn(store, 'dispatch').mockResolvedValue();
await findAddCommentNowButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith(

View File

@ -136,7 +136,7 @@ describe('GroupsView', () => {
expect(successHandler).toHaveBeenCalledWith({
id: defaultProvide.organizationGid,
search: defaultPropsData.search,
sort: 'NAME_ASC',
sort: 'name_asc',
last: null,
first: DEFAULT_PER_PAGE,
before: null,
@ -233,7 +233,7 @@ describe('GroupsView', () => {
id: defaultProvide.organizationGid,
last: null,
search: defaultPropsData.search,
sort: 'NAME_ASC',
sort: 'name_asc',
});
});
});
@ -296,7 +296,7 @@ describe('GroupsView', () => {
id: defaultProvide.organizationGid,
last: DEFAULT_PER_PAGE,
search: defaultPropsData.search,
sort: 'NAME_ASC',
sort: 'name_asc',
});
});
});

View File

@ -50,6 +50,14 @@ describe('popovers/components/popovers.vue', () => {
expect(wrapper.findAllComponents(GlPopover)).toHaveLength(1);
});
describe('title', () => {
it('does not render an empty header when there is no title', async () => {
const target = createPopoverTarget({ title: '' });
await buildWrapper(target);
expect(wrapper.find('.popover-header').exists()).toBe(false);
});
});
describe('supports HTML content', () => {
const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>';
const escapedSvgIcon = '<svg><use xlink:href=&quot;icons.svg#test&quot;></use></svg>';

View File

@ -1,35 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import ChildContent from '~/vue_merge_request_widget/components/extensions/child_content.vue';
let wrapper;
const mockData = () => ({
header: 'Test header',
text: 'Test content',
icon: {
name: 'error',
},
});
function factory(propsData) {
wrapper = shallowMount(ChildContent, {
propsData: {
...propsData,
widgetLabel: 'Test',
},
});
}
describe('MR widget extension child content', () => {
it('renders child components', () => {
factory({
data: {
...mockData(),
children: [mockData()],
},
level: 2,
});
expect(wrapper.find('[data-testid="child-content"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="child-content"]').props('level')).toBe(3);
});
});

View File

@ -1,34 +0,0 @@
import {
registerExtension,
registeredExtensions,
} from '~/vue_merge_request_widget/components/extensions';
import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue';
describe('MR widget extension registering', () => {
it('registers a extension', () => {
registerExtension({
name: 'Test',
props: ['helloWorld'],
computed: {
test() {},
},
methods: {
test() {},
},
});
expect(registeredExtensions.extensions[0]).toEqual(
expect.objectContaining({
extends: ExtensionBase,
name: 'Test',
computed: {
helloWorld: expect.any(Function),
test: expect.any(Function),
},
methods: {
test: expect.any(Function),
},
}),
);
});
});

View File

@ -1,32 +0,0 @@
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
let wrapper;
function factory(propsData = {}) {
wrapper = shallowMount(StatusIcon, {
propsData,
});
}
describe('MR widget extensions status icon', () => {
it('renders loading icon', () => {
factory({ name: 'test', isLoading: true, iconName: 'failed' });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('renders status icon', () => {
factory({ name: 'test', isLoading: false, iconName: 'failed' });
expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed');
});
it('sets aria-label for status icon', () => {
factory({ name: 'test', isLoading: false, iconName: 'failed' });
expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test');
});
});

View File

@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import MergeChecksComponent from '~/vue_merge_request_widget/components/merge_checks.vue';
import mergeChecksQuery from '~/vue_merge_request_widget/queries/merge_checks.query.graphql';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue';
import StateContainer from '~/vue_merge_request_widget/components/state_container.vue';
import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants';
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';

View File

@ -1,7 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue';
describe('MR widget status icon component', () => {
let wrapper;

View File

@ -1,4 +1,3 @@
import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@ -9,18 +8,11 @@ import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/re
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { setFaviconOverlay } from '~/lib/utils/favicon';
import notify from '~/lib/utils/notify';
import SmartInterval from '~/smart_interval';
import {
registerExtension,
registeredExtensions,
} from '~/vue_merge_request_widget/components/extensions';
import { STATUS_CLOSED, STATUS_OPEN, STATUS_MERGED } from '~/issues/constants';
import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget/constants';
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
@ -34,7 +26,6 @@ import MergedState from '~/vue_merge_request_widget/components/states/mr_widget_
import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue';
import WidgetSuggestPipeline from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue';
import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql';
import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
@ -45,21 +36,9 @@ import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/componen
import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql';
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import ExtensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
import mockData, { mockDeployment, mockMergePipeline, mockPostMergeDeployments } from './mock_data';
import {
workingExtension,
collapsedDataErrorExtension,
fullDataErrorExtension,
fullReportExtension,
noTelemetryExtension,
pollingExtension,
pollingFullDataExtension,
pollingErrorExtension,
multiPollingExtension,
} from './test_extensions';
jest.mock('~/api.js');
@ -160,10 +139,6 @@ describe('MrWidgetOptions', () => {
const findPipelineContainer = () => wrapper.findByTestId('pipeline-container');
const findAlertMessage = () => wrapper.findComponent(MrWidgetAlertMessage);
const findMergePipelineForkAlert = () => wrapper.findByTestId('merge-pipeline-fork-warning');
const findExtensionToggleButton = () =>
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]');
const findExtensionLink = (linkHref) =>
wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`);
const findSuggestPipeline = () => wrapper.findComponent(WidgetSuggestPipeline);
const findWidgetContainer = () => wrapper.findComponent(WidgetContainer);
@ -718,397 +693,6 @@ describe('MrWidgetOptions', () => {
});
});
describe('mock extension', () => {
beforeEach(() => {
registerExtension(workingExtension());
createComponent({ mountFn: mountExtended });
});
afterEach(() => {
registeredExtensions.extensions = [];
});
it('renders collapsed data', async () => {
await waitForPromises();
expect(wrapper.text()).toContain('Test extension summary count: 1');
});
it('renders full data', async () => {
await waitForPromises();
findExtensionToggleButton().trigger('click');
await nextTick();
expect(
wrapper
.find('[data-testid="widget-extension-top-level"]')
.findComponent(GlDropdown)
.exists(),
).toBe(false);
await waitForPromises();
const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]');
expect(collapsedSection.exists()).toBe(true);
expect(collapsedSection.text()).toContain('Hello world');
// Renders icon in the row
expect(collapsedSection.findComponent(GlIcon).exists()).toBe(true);
expect(collapsedSection.findComponent(GlIcon).props('name')).toBe('status-failed');
// Renders badge in the row
expect(collapsedSection.findComponent(GlBadge).exists()).toBe(true);
expect(collapsedSection.findComponent(GlBadge).text()).toBe('Closed');
// Renders a link in the row
expect(collapsedSection.findComponent(GlLink).exists()).toBe(true);
expect(collapsedSection.findComponent(GlLink).text()).toBe('GitLab.com');
expect(collapsedSection.findComponent(GlButton).exists()).toBe(true);
expect(collapsedSection.findComponent(GlButton).text()).toBe('Full report');
});
});
describe('expansion', () => {
it('hides collapse button', async () => {
registerExtension(workingExtension(false));
await createComponent();
expect(findExtensionToggleButton().exists()).toBe(false);
});
it('shows collapse button', async () => {
registerExtension(workingExtension(true));
await createComponent({ mountFn: mountExtended });
expect(findExtensionToggleButton().exists()).toBe(true);
});
});
describe('mock polling extension', () => {
let pollRequest;
const findWidgetTestExtension = () => wrapper.find('[data-testid="widget-extension"]');
beforeEach(() => {
pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
registeredExtensions.extensions = [];
});
afterEach(() => {
registeredExtensions.extensions = [];
});
describe('success - multi polling', () => {
it('sets data when polling is complete', async () => {
registerExtension(
multiPollingExtension([
() =>
Promise.resolve({
headers: { 'poll-interval': 0 },
status: HTTP_STATUS_OK,
data: { reports: 'parsed' },
}),
() =>
Promise.resolve({
status: HTTP_STATUS_OK,
data: { reports: 'parsed' },
}),
]),
);
await createComponent({ mountFn: mountExtended });
expect(findWidgetTestExtension().html()).toContain(
'Multi polling test extension reports: parsed, count: 2',
);
});
it('shows loading state until polling is complete', async () => {
registerExtension(
multiPollingExtension([
() =>
Promise.resolve({
headers: { 'poll-interval': 1 },
status: HTTP_STATUS_NO_CONTENT,
}),
() =>
Promise.resolve({
status: HTTP_STATUS_OK,
data: { reports: 'parsed' },
}),
]),
);
await createComponent({ mountFn: mountExtended });
expect(findWidgetTestExtension().html()).toContain('Test extension loading...');
});
});
describe('success', () => {
it('does not make additional requests after poll is successful', async () => {
registerExtension(pollingExtension);
await createComponent({ mountFn: mountExtended });
expect(pollRequest).toHaveBeenCalledTimes(1);
});
});
describe('success - full data polling', () => {
it('sets data when polling is complete', async () => {
registerExtension(pollingFullDataExtension);
await createComponent({ mountFn: mountExtended });
api.trackRedisHllUserEvent.mockClear();
api.trackRedisCounterEvent.mockClear();
findExtensionToggleButton().trigger('click');
// The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_expand',
);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_expand_warning',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_expand',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_expand_warning',
);
});
});
describe('error', () => {
it('does not make additional requests after poll has failed', async () => {
registerExtension(pollingErrorExtension);
await createComponent({ mountFn: mountExtended });
expect(pollRequest).toHaveBeenCalledTimes(1);
});
it('captures sentry error and displays error when poll has failed', async () => {
registerExtension(pollingErrorExtension);
await createComponent({ mountFn: mountExtended });
expect(Sentry.captureException).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
});
describe('mock extension errors', () => {
afterEach(() => {
registeredExtensions.extensions = [];
});
it('handles collapsed data fetch errors', async () => {
registerExtension(collapsedDataErrorExtension);
await createComponent({ mountFn: mountExtended });
expect(
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
).toBe(false);
expect(Sentry.captureException).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
it('handles full data fetch errors', async () => {
registerExtension(fullDataErrorExtension);
await createComponent({ mountFn: mountExtended });
expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
wrapper
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await nextTick();
await waitForPromises();
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
describe('telemetry', () => {
afterEach(() => {
registeredExtensions.extensions = [];
});
describe('component name tier suffixes', () => {
let extension;
beforeEach(() => {
extension = workingExtension();
});
it('reports events without a CE suffix', async () => {
extension.name = `${extension.name}CE`;
registerExtension(extension);
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_view',
);
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_c_e_view',
);
});
it('reports events without a EE suffix', async () => {
extension.name = `${extension.name}EE`;
registerExtension(extension);
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_view',
);
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_e_e_view',
);
});
it('leaves non-CE & non-EE all caps suffixes intact', async () => {
extension.name = `${extension.name}HI`;
registerExtension(extension);
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_view',
);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_h_i_view',
);
});
it("doesn't remove CE or EE from the middle of a widget name", async () => {
extension.name = 'TestCEExtensionEETest';
registerExtension(extension);
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_c_e_extension_e_e_test_view',
);
});
});
it('triggers view events when mounted', async () => {
registerExtension(workingExtension());
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_view',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_view',
);
});
describe('expand button', () => {
it('triggers expand events when clicked', async () => {
registerExtension(workingExtension());
createComponent({ mountFn: mountExtended });
await waitForPromises();
api.trackRedisHllUserEvent.mockClear();
api.trackRedisCounterEvent.mockClear();
findExtensionToggleButton().trigger('click');
// The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_expand',
);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_expand_warning',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_expand',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_expand_warning',
);
});
});
it('triggers the "full report clicked" events when the appropriate button is clicked', async () => {
registerExtension(fullReportExtension);
await createComponent({
mountFn: mountExtended,
options: { stubs: { ExtensionsContainer } },
});
api.trackRedisHllUserEvent.mockClear();
api.trackRedisCounterEvent.mockClear();
findExtensionLink('testref').trigger('click');
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_click_full_report',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
'i_code_review_merge_request_widget_test_extension_count_click_full_report',
);
});
describe('when disabled', () => {
afterEach(() => {
registeredExtensions.extensions = [];
});
it("doesn't emit any telemetry events", async () => {
registerExtension(noTelemetryExtension);
createComponent({ mountFn: mountExtended });
await waitForPromises();
findExtensionToggleButton().trigger('click');
findExtensionLink('testref').trigger('click'); // The "full report" link
expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled();
expect(api.trackRedisCounterEvent).not.toHaveBeenCalled();
});
});
});
describe('widget container', () => {
it('renders the widget container when there is MR data', async () => {
await createComponent(mockData);

View File

@ -1,193 +0,0 @@
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
export const workingExtension = (shouldCollapse = true) => ({
name: 'WidgetTestExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
i18n: {
loading: 'Test extension loading...',
},
computed: {
summary({ count, targetProjectFullPath } = {}) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count } = {}) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
shouldCollapse() {
return shouldCollapse;
},
},
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
return Promise.resolve({ targetProjectFullPath, count: 1 });
},
fetchFullData() {
return Promise.resolve([
{
id: 1,
text: 'Hello world',
icon: {
name: EXTENSION_ICONS.failed,
},
badge: {
text: 'Closed',
},
link: {
href: 'https://gitlab.com',
text: 'GitLab.com',
},
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
},
]);
},
},
});
export const collapsedDataErrorExtension = {
name: 'WidgetTestCollapsedErrorExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
computed: {
summary({ count, targetProjectFullPath }) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count }) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData() {
return Promise.reject(new Error('Fetch error'));
},
fetchFullData() {
return Promise.resolve([
{
id: 1,
text: 'Hello world',
icon: {
name: EXTENSION_ICONS.failed,
},
badge: {
text: 'Closed',
},
link: {
href: 'https://gitlab.com',
text: 'GitLab.com',
},
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
},
]);
},
},
};
export const fullDataErrorExtension = {
name: 'WidgetTestCollapsedErrorExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
computed: {
summary({ count, targetProjectFullPath }) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count }) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
return Promise.resolve({ targetProjectFullPath, count: 1 });
},
fetchFullData() {
return Promise.reject(new Error('Fetch error'));
},
},
};
export const pollingExtension = {
...workingExtension(),
enablePolling: true,
};
export const pollingFullDataExtension = {
...workingExtension(),
enableExpandedPolling: true,
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
return Promise.resolve({ targetProjectFullPath, count: 1 });
},
fetchFullData() {
return Promise.resolve([
{
headers: { 'poll-interval': 0 },
status: HTTP_STATUS_OK,
data: {
id: 1,
text: 'Hello world',
icon: {
name: EXTENSION_ICONS.failed,
},
badge: {
text: 'Closed',
},
link: {
href: 'https://gitlab.com',
text: 'GitLab.com',
},
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
},
},
]);
},
},
};
export const fullReportExtension = {
...workingExtension(),
computed: {
...workingExtension().computed,
tertiaryButtons() {
return [
{
text: 'test',
href: `testref`,
target: '_blank',
trackFullReportClicked: true,
},
];
},
},
};
export const noTelemetryExtension = {
...fullReportExtension,
telemetry: false,
};
export const multiPollingExtension = (endpointsToBePolled) => ({
name: 'WidgetTestMultiPollingExtension',
props: [],
i18n: {
loading: 'Test extension loading...',
},
computed: {
summary(data) {
return `Multi polling test extension reports: ${data?.[0]?.reports}, count: ${data.length}`;
},
statusIcon(data) {
return data?.[0]?.reports === 'parsed' ? EXTENSION_ICONS.success : EXTENSION_ICONS.warning;
},
},
enablePolling: true,
methods: {
fetchMultiData() {
return endpointsToBePolled;
},
},
});
export const pollingErrorExtension = {
...collapsedDataErrorExtension,
enablePolling: true,
};

View File

@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Environment'] do
expected_fields = %w[
name id state latest_opened_most_severe_alert path external_url deployments
slug createdAt updatedAt autoStopAt autoDeleteAt tier environmentType lastDeployment deployFreezes
clusterAgent
clusterAgent deploymentsDisplayCount
]
expect(described_class).to include_graphql_fields(*expected_fields)

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['OrganizationGroupSort'], feature_category: :cell do
let(:sort_values) do
%w[
ID_ASC
ID_DESC
NAME_ASC
NAME_DESC
PATH_ASC
PATH_DESC
UPDATED_AT_ASC
UPDATED_AT_DESC
CREATED_AT_ASC
CREATED_AT_DESC
]
end
specify { expect(described_class.graphql_name).to eq('OrganizationGroupSort') }
it 'exposes all the organization groups sort values' do
expect(described_class.values.keys).to include(*sort_values)
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EnvironmentPresenter, feature_category: :continuous_delivery do
subject(:presenter) { described_class.new(environment) }
let_it_be(:environment) { build(:environment) }
describe '#deployments_display_count' do
subject { presenter.deployments_display_count }
before do
stub_const("#{described_class}::MAX_DEPLOYMENTS_COUNT", 5)
allow(environment).to receive_message_chain(:deployments,
:limit).with(described_class::MAX_DEPLOYMENTS_COUNT).and_return(deployments_list)
end
context 'with less than the maximum deployments' do
let_it_be(:deployments_list) { build_stubbed_list(:deployment, 3) }
it 'returns the actual deployments count' do
is_expected.to eq('3')
end
end
context 'with more than the maximum deployments' do
let_it_be(:deployments_list) { build_stubbed_list(:deployment, 6) }
it 'returns MAX_DISPLAY_COUNT value' do
is_expected.to eq(described_class::MAX_DISPLAY_COUNT)
end
end
end
end

View File

@ -4,10 +4,12 @@ require 'spec_helper'
RSpec.describe 'searching groups', :with_license, feature_category: :groups_and_projects do
include GraphqlHelpers
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
let_it_be(:public_group) { create(:group, :public) }
let_it_be(:private_group) { create(:group, :private) }
let(:current_user) { user }
let(:fields) do
<<~FIELDS
@ -73,4 +75,35 @@ RSpec.describe 'searching groups', :with_license, feature_category: :groups_and_
end
end
end
describe 'group sorting' do
let_it_be(:public_group2) { create(:group, :public) }
let_it_be(:public_group3) { create(:group, :public) }
let_it_be(:all_groups) { [public_group, public_group2, public_group3] }
let_it_be(:first_param) { 2 }
let_it_be(:data_path) { [:groups] }
where(:field, :direction, :sorted_groups) do
'id' | 'asc' | lazy { all_groups.sort_by(&:id) }
'id' | 'desc' | lazy { all_groups.sort_by(&:id).reverse }
'name' | 'asc' | lazy { all_groups.sort_by(&:name) }
'name' | 'desc' | lazy { all_groups.sort_by(&:name).reverse }
'path' | 'asc' | lazy { all_groups.sort_by(&:path) }
'path' | 'desc' | lazy { all_groups.sort_by(&:path).reverse }
end
with_them do
it_behaves_like 'sorted paginated query' do
let(:sort_param) { "#{field}_#{direction}" }
let(:all_records) { sorted_groups.map { |p| global_id_of(p).to_s } }
end
end
def pagination_query(params)
graphql_query_for(
'', {},
query_nodes(:groups, :id, include_pagination_info: true, args: params)
)
end
end
end

View File

@ -154,8 +154,10 @@ RSpec.describe 'getting organization information', feature_category: :cell do
end
end
context 'with `sort` argument' do
let(:authorized_groups) { [public_group, private_group, other_group] }
describe 'group sorting' do
let_it_be(:authorized_groups) { [public_group, private_group, other_group] }
let_it_be(:first_param) { 2 }
let_it_be(:data_path) { [:organization, :groups] }
where(:field, :direction, :sorted_groups) do
'id' | 'asc' | lazy { authorized_groups.sort_by(&:id) }
@ -167,24 +169,17 @@ RSpec.describe 'getting organization information', feature_category: :cell do
end
with_them do
let(:sort) { "#{field}_#{direction}".upcase }
let(:organization_fields) do
<<~FIELDS
id
path
groups(sort: #{sort}) {
nodes {
id
}
}
FIELDS
it_behaves_like 'sorted paginated query' do
let(:sort_param) { "#{field}_#{direction}" }
let(:all_records) { sorted_groups.map { |p| global_id_of(p).to_s } }
end
end
it 'sorts the groups' do
request_organization
expect(groups.pluck('id')).to eq(sorted_groups.map(&:to_global_id).map(&:to_s))
end
def pagination_query(params)
graphql_query_for(
:organization, { id: organization.to_global_id },
query_nodes(:groups, :id, include_pagination_info: true, args: params)
)
end
end
end
@ -215,6 +210,34 @@ RSpec.describe 'getting organization information', feature_category: :cell do
expect(projects).to contain_exactly(a_graphql_entity_for(project))
end
describe 'project searching' do
let_it_be(:other_project) do
create(:project, name: 'other-project', organization: organization) { |p| p.add_developer(user) }
end
let_it_be(:non_member_project) { create(:project, :public, organization: organization) }
context 'with `search` argument' do
let(:search) { 'other' }
let(:organization_fields) do
<<~FIELDS
projects(search: "#{search}") {
nodes {
id
name
}
}
FIELDS
end
it 'filters projects by name' do
request_organization
expect(projects).to contain_exactly(a_graphql_entity_for(other_project))
end
end
end
describe 'project sorting' do
let_it_be(:another_project) { create(:project, organization: organization) { |p| p.add_developer(user) } }
let_it_be(:another_project2) { create(:project, organization: organization) { |p| p.add_developer(user) } }

View File

@ -106,6 +106,13 @@ p_ci_job_artifacts:
- p_ci_job_artifacts_project_id_idx
p_ci_job_artifacts_project_id_id_idx1:
- p_ci_job_artifacts_project_id_idx
p_ci_stages:
p_ci_stages_pipeline_id_name_partition_id_idx:
- p_ci_stages_pipeline_id_idx
p_ci_stages_pipeline_id_idx:
- p_ci_stages_pipeline_id_idx
p_ci_stages_pipeline_id_position_idx:
- p_ci_stages_pipeline_id_idx
pages_domains:
index_pages_domains_on_project_id_and_enabled_until:
- index_pages_domains_on_project_id