Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-10-24 21:11:45 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-10-24 21:11:45 +0300
commit4bb797f25563205cf495f4dd5366e037e88831ab (patch)
treea345ddbd0e2464067323d3c6fd34960607ef4f44
parent40a4f37126bb1a1dd6b6f4b3c0ebb414a3e3908a (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/gitlab-com/danger-review.gitlab-ci.yml2
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue39
-rw-r--r--app/assets/javascripts/batch_comments/queries/can_approve.query.graphql11
-rw-r--r--app/assets/javascripts/ci/common/private/job_action_component.vue14
-rw-r--r--app/assets/javascripts/ci/common/private/job_links_layer.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue19
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue20
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue14
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue20
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue39
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue42
-rw-r--r--app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue58
-rw-r--r--app/assets/javascripts/diffs/components/app.vue21
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/store/actions.js3
-rw-r--r--app/assets/javascripts/search/sidebar/components/blobs_filters.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue9
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss77
-rw-r--r--app/controllers/projects/group_links_controller.rb51
-rw-r--r--app/helpers/visibility_level_helper.rb10
-rw-r--r--app/models/ci/sources/pipeline.rb2
-rw-r--r--app/models/integration.rb19
-rw-r--r--app/models/integrations/apple_app_store.rb6
-rw-r--r--app/models/integrations/asana.rb6
-rw-r--r--app/models/integrations/assembla.rb4
-rw-r--r--app/models/integrations/bamboo.rb6
-rw-r--r--app/models/integrations/base_chat_notification.rb4
-rw-r--r--app/models/integrations/base_slack_notification.rb2
-rw-r--r--app/models/integrations/bugzilla.rb6
-rw-r--r--app/models/integrations/buildkite.rb12
-rw-r--r--app/models/integrations/campfire.rb6
-rw-r--r--app/models/integrations/clickup.rb6
-rw-r--r--app/models/integrations/confluence.rb4
-rw-r--r--app/models/integrations/custom_issue_tracker.rb6
-rw-r--r--app/models/integrations/datadog.rb6
-rw-r--r--app/models/integrations/discord.rb14
-rw-r--r--app/models/integrations/drone_ci.rb12
-rw-r--r--app/models/integrations/emails_on_push.rb4
-rw-r--r--app/models/integrations/ewm.rb6
-rw-r--r--app/models/integrations/external_wiki.rb14
-rw-r--r--app/models/integrations/gitlab_slack_application.rb4
-rw-r--r--app/models/integrations/google_play.rb6
-rw-r--r--app/models/integrations/hangouts_chat.rb6
-rw-r--r--app/models/integrations/harbor.rb26
-rw-r--r--app/models/integrations/irker.rb38
-rw-r--r--app/models/integrations/jenkins.rb6
-rw-r--r--app/models/integrations/jira.rb20
-rw-r--r--app/models/integrations/mattermost.rb6
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb4
-rw-r--r--app/models/integrations/microsoft_teams.rb6
-rw-r--r--app/models/integrations/mock_ci.rb4
-rw-r--r--app/models/integrations/mock_monitoring.rb4
-rw-r--r--app/models/integrations/packagist.rb4
-rw-r--r--app/models/integrations/pipelines_email.rb4
-rw-r--r--app/models/integrations/pivotaltracker.rb6
-rw-r--r--app/models/integrations/prometheus.rb4
-rw-r--r--app/models/integrations/pumble.rb6
-rw-r--r--app/models/integrations/pushover.rb4
-rw-r--r--app/models/integrations/redmine.rb6
-rw-r--r--app/models/integrations/shimo.rb4
-rw-r--r--app/models/integrations/slack.rb4
-rw-r--r--app/models/integrations/slack_slash_commands.rb4
-rw-r--r--app/models/integrations/squash_tm.rb6
-rw-r--r--app/models/integrations/teamcity.rb6
-rw-r--r--app/models/integrations/telegram.rb6
-rw-r--r--app/models/integrations/unify_circuit.rb6
-rw-r--r--app/models/integrations/webex_teams.rb6
-rw-r--r--app/models/integrations/youtrack.rb6
-rw-r--r--app/models/integrations/zentao.rb8
-rw-r--r--app/serializers/merge_request_noteable_entity.rb4
-rw-r--r--app/services/projects/group_links/destroy_service.rb33
-rw-r--r--app/services/projects/group_links/update_service.rb8
-rw-r--r--app/workers/remove_expired_group_links_worker.rb2
-rw-r--r--config/feature_flags/development/new_pipeline_graph.yml8
-rw-r--r--doc/api/graphql/reference/index.md56
-rw-r--r--doc/development/fe_guide/style/typescript.md109
-rw-r--r--doc/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.md53
-rw-r--r--doc/security/unlock_user.md10
-rw-r--r--doc/user/custom_roles.md6
-rw-r--r--doc/user/product_analytics/index.md2
-rw-r--r--doc/user/product_analytics/instrumentation/browser_sdk.md251
-rw-r--r--doc/user/product_analytics/instrumentation/index.md15
-rw-r--r--doc/user/project/merge_requests/revert_changes.md5
-rw-r--r--lib/api/projects.rb7
-rw-r--r--lib/gitlab/project_template.rb4
-rw-r--r--locale/gitlab.pot9
-rw-r--r--qa/lib/gitlab/page/subscriptions/new.rb15
-rw-r--r--qa/lib/gitlab/page/subscriptions/new.stub.rb24
-rw-r--r--qa/qa/flow/purchase.rb6
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb56
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_noteable.json6
-rw-r--r--spec/frontend/batch_comments/components/submit_dropdown_spec.js48
-rw-r--r--spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js91
-rw-r--r--spec/frontend/diffs/components/app_spec.js51
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js4
-rw-r--r--spec/frontend/search/sidebar/components/blobs_filters_spec.js39
-rw-r--r--spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js39
-rw-r--r--spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js88
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js19
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb30
-rw-r--r--spec/models/integration_spec.rb12
-rw-r--r--spec/models/integrations/base_chat_notification_spec.rb6
-rw-r--r--spec/requests/api/projects_spec.rb10
-rw-r--r--spec/services/projects/group_links/create_service_spec.rb95
-rw-r--r--spec/services/projects/group_links/destroy_service_spec.rb143
-rw-r--r--spec/services/projects/group_links/update_service_spec.rb121
-rw-r--r--vendor/project_templates/bridgetown.tar.gzbin42296 -> 38125 bytes
-rw-r--r--vendor/project_templates/middleman.tar.gzbin9631 -> 37406 bytes
112 files changed, 1806 insertions, 565 deletions
diff --git a/.gitlab/ci/gitlab-com/danger-review.gitlab-ci.yml b/.gitlab/ci/gitlab-com/danger-review.gitlab-ci.yml
index 0b937f01227..8a1a0014539 100644
--- a/.gitlab/ci/gitlab-com/danger-review.gitlab-ci.yml
+++ b/.gitlab/ci/gitlab-com/danger-review.gitlab-ci.yml
@@ -1,6 +1,6 @@
include:
- project: gitlab-org/quality/pipeline-common
- ref: 7.6.1
+ ref: 7.7.0
file:
- /ci/danger-review.yml
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 0527b56b464..4b9fe01e997 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -6,11 +6,29 @@ import { __ } from '~/locale';
import { createAlert } from '~/alert';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
+import { fetchPolicies } from '~/lib/graphql';
import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants';
import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub';
import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking';
+import userCanApproveQuery from '../queries/can_approve.query.graphql';
export default {
+ apollo: {
+ userPermissions: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: userCanApproveQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath.replace(/^\//, ''),
+ iid: `${this.getNoteableData.iid}`,
+ };
+ },
+ update: (data) => data.project?.mergeRequest?.userPermissions,
+ skip() {
+ return !this.dropdownVisible;
+ },
+ },
+ },
components: {
GlDisclosureDropdown,
GlButton,
@@ -28,6 +46,7 @@ export default {
data() {
return {
isSubmitting: false,
+ dropdownVisible: false,
noteData: {
noteable_type: '',
noteable_id: '',
@@ -42,11 +61,13 @@ export default {
'aria-label': __('Comment'),
'data-testid': 'comment-textarea',
},
+ userPermissions: {},
};
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
...mapState('batchComments', ['shouldAnimateReviewButton']),
+ ...mapState('diffs', ['projectPath']),
autocompleteDataSources() {
return gl.GfmAutoComplete?.dataSources;
},
@@ -60,6 +81,17 @@ export default {
this.repositionDropdown();
});
},
+ dropdownVisible(val) {
+ if (!val) {
+ this.userPermissions = {};
+ }
+ },
+ userPermissions: {
+ handler() {
+ this.repositionDropdown();
+ },
+ deep: true,
+ },
},
mounted() {
this.noteData.noteable_type = this.noteableType;
@@ -112,6 +144,9 @@ export default {
preventDefault();
}
},
+ setDropdownVisible(val) {
+ this.dropdownVisible = val;
+ },
},
restrictedToolbarItems: ['full-screen'],
};
@@ -126,6 +161,8 @@ export default {
data-testid="submit-review-dropdown"
fluid-width
@beforeClose="onBeforeClose"
+ @shown="setDropdownVisible(true)"
+ @hidden="setDropdownVisible(false)"
>
<template #toggle>
<gl-button variant="info" category="primary">
@@ -171,7 +208,7 @@ export default {
@keydown.ctrl.enter="submitReview"
/>
</div>
- <template v-if="getNoteableData.current_user.can_approve">
+ <template v-if="userPermissions.canApprove">
<gl-form-checkbox
v-model="noteData.approve"
data-testid="approve_merge_request"
diff --git a/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql b/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql
new file mode 100644
index 00000000000..f0c9ef7b3c8
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql
@@ -0,0 +1,11 @@
+query userCanApprove($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ id
+ mergeRequest(iid: $iid) {
+ id
+ userPermissions {
+ canApprove
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/common/private/job_action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue
index b0fa724d450..c266e061513 100644
--- a/app/assets/javascripts/ci/common/private/job_action_component.vue
+++ b/app/assets/javascripts/ci/common/private/job_action_component.vue
@@ -119,6 +119,7 @@ export default {
ref="button"
:class="cssClass"
:disabled="isDisabled"
+ size="small"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
data-testid="ci-action-button"
@click.stop="onClickAction"
@@ -129,8 +130,17 @@ export default {
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full"
data-testid="ci-action-icon-tooltip-wrapper"
>
- <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
- <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
+ <gl-loading-icon
+ v-if="isLoading"
+ size="sm"
+ class="gl-button-icon gl-m-2 js-action-icon-loading"
+ />
+ <gl-icon
+ v-else
+ :name="actionIcon"
+ class="gl-button-icon gl-p-1 gl-mr-0!"
+ :aria-label="actionIcon"
+ />
</div>
</gl-button>
</template>
diff --git a/app/assets/javascripts/ci/common/private/job_links_layer.vue b/app/assets/javascripts/ci/common/private/job_links_layer.vue
index 59260ca3f81..9b3647e9c55 100644
--- a/app/assets/javascripts/ci/common/private/job_links_layer.vue
+++ b/app/assets/javascripts/ci/common/private/job_links_layer.vue
@@ -1,5 +1,6 @@
<script>
import { memoize } from 'lodash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { reportToSentry } from '~/ci/utils';
import { parseData } from '~/ci/pipeline_details/utils/parsing_utils';
import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue';
@@ -16,6 +17,7 @@ export default {
components: {
LinksInner,
},
+ mixins: [glFeatureFlagMixin()],
props: {
containerMeasurements: {
type: Object,
@@ -50,6 +52,9 @@ export default {
showLinkedLayers() {
return this.showLinks && !this.containerZero;
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -68,7 +73,10 @@ export default {
<slot></slot>
</links-inner>
<div v-else>
- <div class="gl-display-flex gl-relative">
+ <div
+ class="gl-display-flex gl-relative"
+ :class="{ 'gl-flex-wrap gl-sm-flex-nowrap': isNewPipelineGraph }"
+ >
<slot></slot>
</div>
</div>
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue
index f098d790736..fce7aabf0cf 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue
@@ -4,6 +4,7 @@ import {
generateColumnsFromLayersListMemoized,
keepLatestDownstreamPipelines,
} from '~/ci/pipeline_details/utils/parsing_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LinksLayer from '../../../common/private/job_links_layer.vue';
import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from '../constants';
import { validateConfigPaths } from '../utils';
@@ -19,6 +20,7 @@ export default {
LinkedPipelinesColumn,
StageColumnComponent,
},
+ mixins: [glFeatureFlagMixin()],
props: {
configPaths: {
type: Object,
@@ -132,6 +134,9 @@ export default {
upstreamPipelines() {
return this.hasUpstreamPipelines ? this.pipeline.upstream : [];
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -178,10 +183,15 @@ export default {
<div class="js-pipeline-graph">
<div
ref="mainPipelineContainer"
- class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
+ class="pipeline-graph gl-display-flex gl-position-relative gl-white-space-nowrap gl-rounded-lg"
:class="{
- 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline,
+ 'gl-bg-gray-10': !isNewPipelineGraph,
+ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isNewPipelineGraph && !isLinkedPipeline,
+ 'pipeline-graph-container gl-bg-gray-10 gl-pipeline-min-h gl-align-items-flex-start gl-pt-3 gl-pb-8 gl-mt-3 gl-overflow-auto':
+ isNewPipelineGraph && !isLinkedPipeline,
+ 'gl-bg-gray-50 gl-sm-ml-5': isNewPipelineGraph && isLinkedPipeline,
}"
+ data-testid="pipeline-container"
>
<linked-graph-wrapper>
<template #upstream>
@@ -199,7 +209,7 @@ export default {
/>
</template>
<template #main>
- <div :id="containerId" :ref="containerId">
+ <div :id="containerId" :ref="containerId" class="pipeline-links-container">
<links-layer
:pipeline-data="layout"
:pipeline-id="pipeline.id"
@@ -238,7 +248,8 @@ export default {
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
- class="gl-mr-6"
+ class="gl-mr-5"
+ :class="{ 'gl-sm-ml-3': isNewPipelineGraph }"
:config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue
index fb7dcb300f1..114b224fbe7 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue
@@ -1,11 +1,11 @@
<script>
import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import { STAGE_VIEW, LAYER_VIEW } from '../constants';
export default {
name: 'GraphViewSelector',
-
components: {
GlAlert,
GlButton,
@@ -13,7 +13,7 @@ export default {
GlLoadingIcon,
GlToggle,
},
-
+ mixins: [glFeatureFlagMixin()],
props: {
showLinks: {
type: Boolean,
@@ -77,6 +77,9 @@ export default {
};
});
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
},
watch: {
/*
@@ -138,7 +141,13 @@ export default {
<template>
<div>
- <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4">
+ <div
+ class="gl-relative gl-display-flex gl-align-items-center gl-my-4"
+ :class="{
+ 'gl-w-max-content': !isNewPipelineGraph,
+ 'gl-flex-wrap gl-sm-flex-nowrap': isNewPipelineGraph,
+ }"
+ >
<gl-loading-icon
v-if="isSwitcherLoading"
data-testid="switcher-loading-state"
@@ -161,7 +170,10 @@ export default {
<gl-toggle
v-model="showLinksActive"
data-testid="show-links-toggle"
- class="gl-mx-4"
+ :class="{
+ 'gl-mx-4': !isNewPipelineGraph,
+ 'gl-sm-ml-4 gl-mt-4 gl-sm-mt-0': isNewPipelineGraph,
+ }"
:label="$options.i18n.linksLabelText"
:is-loading="isToggleLoading"
label-position="left"
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue
index fb2280d971a..9bd0ec6d793 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue
@@ -1,5 +1,17 @@
+<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ mixins: [glFeatureFlagMixin()],
+ computed: {
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
+ },
+};
+</script>
<template>
- <div class="gl-display-flex">
+ <div class="gl-display-flex" :class="{ 'gl-flex-wrap gl-w-full': isNewPipelineGraph }">
<slot name="upstream"></slot>
<slot name="main"></slot>
<slot name="downstream"></slot>
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue
index 511eac79fb5..26521f87426 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue
@@ -7,6 +7,7 @@ import {
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
@@ -29,6 +30,7 @@ export default {
GlLoadingIcon,
GlTooltip,
},
+ mixins: [glFeatureFlagMixin()],
styles: {
actionSizeClasses: ['gl-h-7 gl-w-7'],
flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'],
@@ -115,9 +117,6 @@ export default {
downstreamTitle() {
return this.childPipeline ? this.sourceJobName : this.pipeline.project.name;
},
- flexDirection() {
- return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row';
- },
graphqlPipelineId() {
return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id);
},
@@ -176,6 +175,9 @@ export default {
return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
${this.sourceJobInfo}`;
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`);
@@ -231,9 +233,15 @@ export default {
<template>
<div
ref="linkedPipeline"
- class="gl-h-full gl-display-flex! gl-px-2"
- :class="flexDirection"
+ class="linked-pipeline-container gl-h-full gl-display-flex!"
+ :class="{
+ 'gl-flex-direction-row-reverse': isUpstream,
+ 'gl-flex-direction-row': !isUpstream,
+ 'gl-px-2': !isNewPipelineGraph,
+ 'gl-w-full gl-sm-w-auto': isNewPipelineGraph,
+ }"
data-testid="linked-pipeline-container"
+ :aria-expanded="expanded"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
@@ -250,7 +258,7 @@ export default {
/>
<div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
<div
- class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal"
+ class="gl-display-flex gl-flex-direction-column gl-line-height-normal gl-downstream-pipeline-job-width"
>
<span class="gl-text-truncate" data-testid="downstream-title-content">
{{ downstreamTitle }}
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue
index 2de7e43c9b1..67918ea8d1a 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue
@@ -1,4 +1,5 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { reportToSentry } from '~/ci/utils';
import { LOAD_FAILURE } from '../../constants';
@@ -18,6 +19,7 @@ export default {
LinkedPipeline,
PipelineGraph: () => import('./graph_component.vue'),
},
+ mixins: [glFeatureFlagMixin()],
props: {
columnTitle: {
type: String,
@@ -63,23 +65,30 @@ export default {
'gl-pipeline-job-width',
'gl-text-truncate',
'gl-line-height-36',
- 'gl-pl-3',
- 'gl-mb-5',
],
minWidth: `${ONE_COL_WIDTH}px`,
computed: {
columnClass() {
- const positionValues = {
+ const positionValuesOld = {
right: 'gl-ml-6',
left: 'gl-mx-6',
};
+ const positionValues = {
+ right: 'gl-ml-5',
+ left: 'gl-mx-4 gl-flex-basis-full',
+ };
+ const usePositionValues = this.isNewPipelineGraph ? positionValues : positionValuesOld;
- return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
+ return `graph-position-${this.graphPosition} ${usePositionValues[this.graphPosition]}`;
},
computedTitleClasses() {
const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-linked-pipeline-padding'] : [];
- return [...this.$options.titleClasses, ...positionalClasses];
+ return [
+ ...this.$options.titleClasses,
+ !this.isNewPipelineGraph ?? ['gl-pl-3', 'gl-mb-5'],
+ ...positionalClasses,
+ ];
},
graphPosition() {
return this.isUpstream ? 'left' : 'right';
@@ -93,6 +102,9 @@ export default {
minWidth() {
return this.isUpstream ? 0 : this.$options.minWidth;
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
},
methods: {
getPipelineData(pipeline) {
@@ -197,7 +209,7 @@ export default {
</script>
<template>
- <div class="gl-display-flex">
+ <div class="gl-display-flex" :class="{ 'gl-w-full': isNewPipelineGraph }">
<div :class="columnClass" class="linked-pipelines-column">
<div data-testid="linked-column-title" :class="computedTitleClasses">
{{ columnTitle }}
@@ -206,8 +218,12 @@ export default {
<li
v-for="pipeline in linkedPipelines"
:key="pipeline.id"
- class="gl-display-flex gl-mb-3"
- :class="{ 'gl-flex-direction-row-reverse': isUpstream }"
+ class="gl-display-flex"
+ :class="{
+ 'gl-mb-3': !isNewPipelineGraph,
+ 'gl-flex-wrap gl-sm-flex-nowrap gl-mb-6': isNewPipelineGraph,
+ 'gl-flex-direction-row-reverse': !isNewPipelineGraph && isUpstream,
+ }"
>
<linked-pipeline
class="gl-display-inline-block"
@@ -224,12 +240,15 @@ export default {
<div
v-if="showContainer(pipeline.id)"
:style="{ minWidth }"
- class="gl-display-inline-block"
+ class="gl-display-inline-block pipeline-show-container"
>
<pipeline-graph
v-if="isExpanded(pipeline.id)"
:type="type"
- class="gl-inline-block gl-mt-n2"
+ class="gl-inline-block"
+ :class="{
+ 'gl-mt-n2': !isNewPipelineGraph,
+ }"
:config-paths="configPaths"
:pipeline="currentPipeline"
:computed-pipeline-info="getPipelineLayers(pipeline.id)"
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue
index bcd7705669e..7c07591d0de 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue
@@ -1,5 +1,12 @@
<script>
+import { GlCard } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
export default {
+ components: {
+ GlCard,
+ },
+ mixins: [glFeatureFlagMixin()],
props: {
stageClasses: {
type: String,
@@ -12,18 +19,37 @@ export default {
default: '',
},
},
+ computed: {
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
+ },
};
</script>
<template>
<div>
- <div class="gl-display-flex gl-align-items-center gl-w-full gl-mb-5" :class="stageClasses">
- <slot name="stages"> </slot>
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full"
- :class="jobClasses"
+ <gl-card
+ v-if="isNewPipelineGraph"
+ class="gl-rounded-lg"
+ header-class="gl-rounded-lg gl-px-0 gl-py-0 gl-bg-white gl-border-b-0"
+ body-class="gl-pt-2 gl-pb-0 gl-px-2"
>
- <slot name="jobs"> </slot>
- </div>
+ <template #header>
+ <slot name="stages"></slot>
+ </template>
+
+ <slot name="jobs"></slot>
+ </gl-card>
+ <template v-else>
+ <div class="gl-display-flex gl-align-items-center gl-w-full" :class="stageClasses">
+ <slot name="stages"> </slot>
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full"
+ :class="jobClasses"
+ >
+ <slot name="jobs"> </slot>
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
index 6030adc96ad..e144b9aab0c 100644
--- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
+++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue
@@ -68,7 +68,7 @@ export default {
required: true,
},
},
- jobClasses: [
+ legacyJobClasses: [
'gl-p-3',
'gl-border-gray-100',
'gl-border-solid',
@@ -82,18 +82,43 @@ export default {
'gl-hover-border-gray-200',
'gl-focus-border-gray-200',
],
- titleClasses: [
+ jobClasses: [
+ 'gl-p-3',
+ 'gl-border-0',
+ 'gl-bg-transparent',
+ 'gl-rounded-base',
+ 'gl-hover-bg-gray-50',
+ 'gl-focus-bg-gray-50',
+ 'gl-hover-text-gray-900',
+ 'gl-focus-text-gray-900',
+ ],
+ legacyTitleClasses: [
'gl-font-weight-bold',
'gl-pipeline-job-width',
'gl-text-truncate',
'gl-line-height-36',
'gl-pl-3',
],
+ titleClasses: [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-4',
+ 'gl-mb-n2',
+ ],
computed: {
canUpdatePipeline() {
return this.userPermissions.updatePipeline;
},
columnSpacingClass() {
+ if (this.isNewPipelineGraph) {
+ const baseClasses = 'stage-column gl-relative gl-flex-basis-full';
+ return this.isStageView
+ ? `${baseClasses} is-stage-view gl-m-5`
+ : `${baseClasses} gl-my-5 gl-mx-7`;
+ }
+
return this.isStageView ? 'gl-px-6' : 'gl-px-9';
},
hasAction() {
@@ -102,6 +127,17 @@ export default {
showStageName() {
return !this.isStageView;
},
+ isNewPipelineGraph() {
+ return this.glFeatures.newPipelineGraph;
+ },
+ jobClasses() {
+ return this.isNewPipelineGraph ? this.$options.jobClasses : this.$options.legacyJobClasses;
+ },
+ titleClasses() {
+ return this.isNewPipelineGraph
+ ? this.$options.titleClasses
+ : this.$options.legacyTitleClasses;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('stage_column_component', `error: ${err}, info: ${info}`);
@@ -135,12 +171,16 @@ export default {
};
</script>
<template>
- <root-graph-layout :class="columnSpacingClass" data-testid="stage-column">
+ <root-graph-layout
+ :class="columnSpacingClass"
+ class="stage-column gl-relative gl-flex-basis-full"
+ data-testid="stage-column"
+ >
<template #stages>
<div
data-testid="stage-column-title"
class="gl-display-flex gl-justify-content-space-between gl-relative"
- :class="$options.titleClasses"
+ :class="titleClasses"
>
<span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p">
{{ name }}
@@ -161,7 +201,11 @@ export default {
:id="groupId(group)"
:key="getGroupId(group)"
data-testid="stage-column-group"
- class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
+ class="gl-relative gl-white-space-normal gl-pipeline-job-width"
+ :class="{
+ 'gl-mb-3': !isNewPipelineGraph,
+ 'gl-mb-2': isNewPipelineGraph,
+ }"
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
>
@@ -174,7 +218,7 @@ export default {
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
:stage-name="showStageName ? group.stageName : ''"
- :css-class-job-name="$options.jobClasses"
+ :css-class-job-name="jobClasses"
:class="[
{ 'gl-opacity-3': isFadedOut(group.name) },
'gl-transition-duration-slow gl-transition-timing-function-ease',
@@ -188,7 +232,7 @@ export default {
:group="group"
:stage-name="showStageName ? group.stageName : ''"
:pipeline-id="pipelineId"
- :css-class-job-name="$options.jobClasses"
+ :css-class-job-name="jobClasses"
/>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index a389ab8e355..7c3d6dc8c42 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { debounce } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -16,7 +17,8 @@ import {
import { createAlert } from '~/alert';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, handleLocationHash } from '~/lib/utils/common_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -39,6 +41,7 @@ import {
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
EVT_MR_PREPARED,
+ EVT_DISCUSSIONS_ASSIGNED,
} from '../constants';
import { isCollapsed } from '../utils/diff_file';
@@ -136,6 +139,7 @@ export default {
diffFilesLength: 0,
virtualScrollCurrentIndex: -1,
subscribedToVirtualScrollingEvents: false,
+ autoScrolled: false,
};
},
apollo: {
@@ -358,6 +362,10 @@ export default {
this.adjustView();
this.subscribeToEvents();
+ this.slowHashHandler = debounce(() => {
+ handleLocationHash();
+ this.autoScrolled = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
this.unwatchDiscussions = this.$watch(
() => `${this.flatBlobsList.length}:${this.$store.state.notes.discussions.length}`,
() => {
@@ -420,8 +428,10 @@ export default {
diffsEventHub.$on('diffFilesModified', this.setDiscussions);
diffsEventHub.$on('doneLoadingBatches', this.autoScroll);
diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData);
+ diffsEventHub.$on(EVT_DISCUSSIONS_ASSIGNED, this.handleHash);
},
unsubscribeFromEvents() {
+ diffsEventHub.$off(EVT_DISCUSSIONS_ASSIGNED, this.handleHash);
diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData);
diffsEventHub.$off('doneLoadingBatches', this.autoScroll);
diffsEventHub.$off('diffFilesModified', this.setDiscussions);
@@ -449,6 +459,15 @@ export default {
.catch(() => {});
}
},
+ handleHash() {
+ if (this.viewDiffsFileByFile && !this.autoScrolled) {
+ const file = this.diffs[0];
+
+ if (file && !file.isLoadingFullFile) {
+ requestIdleCallback(() => this.slowHashHandler());
+ }
+ }
+ },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 575cd05ceb8..e48eb10753c 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -82,6 +82,7 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_MR_PREPARED = 'mr:asyncPreparationFinished';
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
+export const EVT_DISCUSSIONS_ASSIGNED = 'mr:diffs:discussionsAssigned';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index ed8ae795bda..d86a88f97b8 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -49,6 +49,7 @@ import {
TRACKING_MULTIPLE_FILES_MODE,
EVT_MR_PREPARED,
FILE_DIFF_POSITION_TYPE,
+ EVT_DISCUSSIONS_ASSIGNED,
} from '../constants';
import {
DISCUSSION_SINGLE_DIFF_FAILED,
@@ -413,7 +414,7 @@ export const assignDiscussionsToDiff = (
}
Vue.nextTick(() => {
- notesEventHub.$emit('scrollToDiscussion');
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
});
};
diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue
index ac36ae6b366..0ed2c24efba 100644
--- a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue
@@ -18,11 +18,8 @@ export default {
computed: {
...mapGetters(['currentScope']),
...mapState(['useSidebarNavigation', 'searchType']),
- showArchivedFilter() {
- return this.glFeatures.searchBlobsHideArchivedProjects;
- },
showDivider() {
- return !this.useSidebarNavigation && this.showArchivedFilter;
+ return !this.useSidebarNavigation;
},
hrClasses() {
return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
@@ -35,6 +32,6 @@ export default {
<filters-template>
<language-filter class="gl-mb-5" />
<hr v-if="showDivider" :class="hrClasses" />
- <archived-filter v-if="showArchivedFilter" class="gl-mb-5" />
+ <archived-filter class="gl-mb-5" />
</filters-template>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js
new file mode 100644
index 00000000000..e15987d7280
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js
@@ -0,0 +1,6 @@
+export const COMPONENTS = {
+ conflicts: () => import('./conflicts.vue'),
+ unresolved_discussions: () => import('./unresolved_discussions.vue'),
+ rebase: () => import('./rebase.vue'),
+ default: () => import('./message.vue'),
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue
new file mode 100644
index 00000000000..c8dba36700c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue
@@ -0,0 +1,37 @@
+<script>
+import { s__ } from '~/locale';
+import notesEventHub from '~/notes/event_hub';
+import ActionButtons from '../action_buttons.vue';
+import MergeChecksMessage from './message.vue';
+
+export default {
+ name: 'MergeChecksUnresolvedDiscussions',
+ components: {
+ MergeChecksMessage,
+ ActionButtons,
+ },
+ props: {
+ check: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tertiaryActionsButtons() {
+ return [
+ {
+ text: s__('mrWidget|Go to first unresolved thread'),
+ category: 'default',
+ onClick: () => notesEventHub.$emit('jumpToFirstUnresolvedDiscussion'),
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <merge-checks-message :check="check">
+ <action-buttons v-if="check.result === 'failed'" :tertiary-buttons="tertiaryActionsButtons" />
+ </merge-checks-message>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue
index 5652b81386f..ecf2987307c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue
@@ -1,17 +1,12 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
-import { n__, __, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
+import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants';
import mergeRequestQueryVariablesMixin from '../mixins/merge_request_query_variables';
import mergeChecksQuery from '../queries/merge_checks.query.graphql';
import StateContainer from './state_container.vue';
import BoldText from './bold_text.vue';
-const COMPONENTS = {
- conflicts: () => import('./checks/conflicts.vue'),
- rebase: () => import('./checks/rebase.vue'),
- default: () => import('./checks/message.vue'),
-};
-
export default {
apollo: {
state: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
index 72c041759d9..d4375690ad1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { generateText } from '../extensions/utils';
import ContentRow from './widget_content_row.vue';
@@ -15,6 +15,7 @@ export default {
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
props: {
data: {
@@ -78,7 +79,11 @@ export default {
<div class="gl-display-flex gl-flex-grow-1">
<div class="gl-display-flex gl-flex-grow-1 gl-align-items-baseline">
<div>
- <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p>
+ <p
+ v-gl-tooltip="{ title: data.tooltipText, boundary: 'viewport' }"
+ v-safe-html="generatedText"
+ class="gl-mb-0 gl-mr-1"
+ ></p>
<gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link>
<p
v-if="data.supportingText"
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 31d45ad3a28..9a1faf27143 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -811,7 +811,7 @@ $ci-action-icon-size: 22px;
$ci-action-icon-size-lg: 24px;
$pipeline-dropdown-line-height: 20px;
$ci-action-dropdown-button-size: 24px;
-$ci-action-dropdown-svg-size: 12px;
+$ci-action-dropdown-svg-size: 16px;
/*
CI variable lists
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 98e9e2b3c27..dcd8f90ab1c 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -125,21 +125,27 @@
// They are here to still access a variable or because they use magic values.
// scoped to the graph. Do not add other styles.
.gl-pipeline-min-h {
- min-height: $dropdown-max-height-lg;
+ min-height: calc(#{$dropdown-max-height-lg} + #{$gl-spacing-scale-6});
}
.gl-pipeline-job-width {
width: 100%;
- max-width: 400px;
}
.gl-pipeline-job-width\! {
width: 100% !important;
- max-width: 400px !important;
}
.gl-downstream-pipeline-job-width {
width: 8rem;
+
+ .pipeline-graph-container & {
+ width: 100%;
+
+ @media (min-width: $breakpoint-sm) {
+ width: 8rem;
+ }
+ }
}
.gl-linked-pipeline-padding {
@@ -154,8 +160,8 @@
// Action Icons in big pipeline-graph nodes
&.ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
+ height: 24px;
+ width: 24px;
border-radius: 100%;
display: block;
padding: 0;
@@ -242,3 +248,64 @@
}
}
}
+
+.pipeline-graph-container {
+ .stage-column.is-stage-view:not(:last-of-type)::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: $gl-spacing-scale-6;
+ width: 2px;
+ height: $gl-spacing-scale-5 * 2;
+ background-color: $gray-200;
+
+ @media (min-width: $breakpoint-sm) {
+ top: 1.25rem;
+ left: 100%;
+ width: $gl-spacing-scale-5 * 2;
+ height: 2px;
+ }
+ }
+
+ .stage-column,
+ .stage-column.is-stage-view {
+ @media (min-width: $breakpoint-sm) {
+ &:first-of-type {
+ margin-left: $gl-spacing-scale-6;
+ }
+ }
+ }
+
+ .linked-pipeline-container[aria-expanded=true] {
+ @media (max-width: $breakpoint-sm) {
+ width: 100%;
+
+ > div {
+ border-bottom-left-radius: 0;
+ }
+
+ > div > button {
+ border-bottom-right-radius: 0 !important;
+ }
+ }
+ }
+
+ .linked-pipelines-column,
+ .pipeline-show-container,
+ .pipeline-links-container {
+ @media (max-width: $breakpoint-sm) {
+ width: 100%;
+ }
+ }
+
+ .pipeline-graph {
+ @media (max-width: $breakpoint-sm) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ }
+
+ .pipeline-graph .pipeline-graph {
+ background-color: $gray-100;
+ }
+}
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 60300f78bbb..5f8bf423219 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -9,30 +9,47 @@ class Projects::GroupLinksController < Projects::ApplicationController
feature_category :groups_and_projects
def update
- Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params)
+ result = Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params)
- if group_link.expires?
- render json: {
- expires_in: helpers.time_ago_with_tooltip(group_link.expires_at),
- expires_soon: group_link.expires_soon?
- }
- else
- render json: {}
+ if result.success?
+ if group_link.expires?
+ render json: {
+ expires_in: helpers.time_ago_with_tooltip(group_link.expires_at),
+ expires_soon: group_link.expires_soon?
+ }
+ else
+ render json: {}
+ end
+ elsif result.reason == :not_found
+ render json: { message: result.message }, status: :not_found
end
end
def destroy
- ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link)
-
- respond_to do |format|
- format.html do
- if can?(current_user, :admin_group, group_link.group)
- redirect_to group_path(group_link.group), status: :found
- elsif can?(current_user, :admin_project, group_link.project)
- redirect_to project_project_members_path(project), status: :found
+ result = ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link)
+
+ if result.success?
+ respond_to do |format|
+ format.html do
+ if can?(current_user, :admin_group, group_link.group)
+ redirect_to group_path(group_link.group), status: :found
+ elsif can?(current_user, :admin_project, group_link.project)
+ redirect_to project_project_members_path(project), status: :found
+ end
+ end
+ format.js { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.html do
+ redirect_to project_project_members_path(project, tab: :groups), status: :found,
+ alert: _('The project-group link could not be removed.')
+ end
+
+ format.js do
+ render json: { message: result.message }, status: :not_found if result.reason == :not_found
end
end
- format.js { head :ok }
end
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 68b15f7e042..cddfc48c649 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -76,16 +76,6 @@ module VisibilityLevelHelper
end
end
- def visibility_level_options(form_model)
- available_visibility_levels(form_model).map do |level|
- {
- level: level,
- label: visibility_level_label(level),
- description: visibility_level_description(level, form_model)
- }
- end
- end
-
def snippets_selected_visibility_level(visibility_levels, selected)
visibility_levels.find { |level| level == selected } || visibility_levels.min
end
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index 5b6946b04fd..475d57ee4c8 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -12,7 +12,7 @@ module Ci
:pipeline_id_convert_to_bigint, :source_pipeline_id_convert_to_bigint
], remove_with: '16.6', remove_after: '2023-10-22'
- columns_changing_default :partition_id
+ columns_changing_default :partition_id, :source_partition_id
self.table_name = "ci_sources_pipelines"
diff --git a/app/models/integration.rb b/app/models/integration.rb
index b4408301c6d..9b268e10cc6 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -237,6 +237,18 @@ class Integration < ApplicationRecord
end
private_class_method :boolean_accessor
+ def self.title
+ raise NotImplementedError
+ end
+
+ def self.description
+ raise NotImplementedError
+ end
+
+ def self.help
+ # no-op
+ end
+
def self.to_param
raise NotImplementedError
end
@@ -447,19 +459,18 @@ class Integration < ApplicationRecord
end
def title
- # implement inside child
+ self.class.title
end
def description
- # implement inside child
+ self.class.description
end
def help
- # implement inside child
+ self.class.help
end
def to_param
- # implement inside child
self.class.to_param
end
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index ef12fc6bf6f..f8fddf8a457 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -37,15 +37,15 @@ module Integrations
title: -> { s_('AppleAppStore|Protected branches and tags only') },
checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') }
- def title
+ def self.title
'Apple App Store Connect'
end
- def description
+ def self.description
s_('AppleAppStore|Use GitLab to build and release an app in the Apple App Store.')
end
- def help
+ def self.help
variable_list = [
'<code>APP_STORE_CONNECT_API_KEY_ISSUER_ID</code>',
'<code>APP_STORE_CONNECT_API_KEY_KEY_ID</code>',
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
index 77555996cd9..39407acd6c9 100644
--- a/app/models/integrations/asana.rb
+++ b/app/models/integrations/asana.rb
@@ -20,15 +20,15 @@ module Integrations
title: -> { s_('Integrations|Restrict to branch (optional)') },
help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') }
- def title
+ def self.title
'Asana'
end
- def description
+ def self.description
s_('AsanaService|Add commit messages as comments to Asana tasks.')
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer'
s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb
index 1d3616b4c3b..bbdd0e183f2 100644
--- a/app/models/integrations/assembla.rb
+++ b/app/models/integrations/assembla.rb
@@ -15,11 +15,11 @@ module Integrations
exposes_secrets: true,
placeholder: ''
- def title
+ def self.title
'Assembla'
end
- def description
+ def self.description
_('Manage projects.')
end
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 9f15532a0b0..9fe73f86be3 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -38,15 +38,15 @@ module Integrations
attr_accessor :response
- def title
+ def self.title
s_('BambooService|Atlassian Bamboo')
end
- def description
+ def self.description
s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo.')
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'),
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index b75801335bd..167bc210349 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -136,10 +136,6 @@ module Integrations
raise NotImplementedError
end
- def help
- raise NotImplementedError
- end
-
# With some integrations the webhook is already tied to a specific channel,
# for others the channels are configurable for each event.
def configurable_channels?
diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb
index 09a0c9ba361..33dd9d9d387 100644
--- a/app/models/integrations/base_slack_notification.rb
+++ b/app/models/integrations/base_slack_notification.rb
@@ -36,7 +36,7 @@ module Integrations
true
end
- def help
+ def self.help
# noop
end
diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb
index 74e282f6848..3ca348e42a1 100644
--- a/app/models/integrations/bugzilla.rb
+++ b/app/models/integrations/bugzilla.rb
@@ -6,15 +6,15 @@ module Integrations
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def title
+ def self.title
'Bugzilla'
end
- def description
+ def self.description
s_("IssueTracker|Use Bugzilla as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index 82a5142e8c2..aab0cdf2134 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -75,20 +75,20 @@ module Integrations
"#{project_url}/builds?commit=#{sha}"
end
- def title
+ def self.title
'Buildkite'
end
- def description
+ def self.description
'Run CI/CD pipelines with Buildkite.'
end
- def self.to_param
- 'buildkite'
+ def self.help
+ s_('ProjectService|Run CI/CD pipelines with Buildkite.')
end
- def help
- s_('ProjectService|Run CI/CD pipelines with Buildkite.')
+ def self.to_param
+ 'buildkite'
end
def calculate_reactive_cache(sha, ref)
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index 8b5797a9d24..18268ed18f4 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -36,15 +36,15 @@ module Integrations
placeholder: '123456',
help: -> { s_('CampfireService|From the end of the room URL.') }
- def title
+ def self.title
'Campfire'
end
- def description
+ def self.description
'Send notifications about push events to Campfire chat rooms.'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('api/integrations', anchor: 'campfire'),
diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb
index 7cc05d41e14..25287b53300 100644
--- a/app/models/integrations/clickup.rb
+++ b/app/models/integrations/clickup.rb
@@ -10,15 +10,15 @@ module Integrations
@reference_pattern ||= /((#|CU-)(?<issue>[a-z0-9]+)|(?<issue>[A-Z0-9_]{2,10}-\d+))\b/
end
- def title
+ def self.title
'ClickUp'
end
- def description
+ def self.description
s_("IssueTracker|Use Clickup as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/clickup'),
target: '_blank',
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index eda8c37fc72..f97f1fd25c9 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -22,11 +22,11 @@ module Integrations
'confluence'
end
- def title
+ def self.title
s_('ConfluenceService|Confluence Workspace')
end
- def description
+ def self.description
s_('ConfluenceService|Link to a Confluence Workspace from the sidebar.')
end
diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb
index 3770e813eaa..fe0d01d60bd 100644
--- a/app/models/integrations/custom_issue_tracker.rb
+++ b/app/models/integrations/custom_issue_tracker.rb
@@ -6,15 +6,15 @@ module Integrations
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def title
+ def self.title
s_('IssueTracker|Custom issue tracker')
end
- def description
+ def self.description
s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer'
s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index b1f1361afcd..5682fc2b139 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -117,15 +117,15 @@ module Integrations
# archive_trace is opt-in but we handle it with a more detailed field below
end
- def title
+ def self.title
'Datadog'
end
- def description
+ def self.description
s_('DatadogIntegration|Trace your GitLab pipelines with Datadog.')
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
s_('DatadogIntegration|How do I set up this integration?'),
Rails.application.routes.url_helpers.help_page_url('integration/datadog'),
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 33b2b52fa62..7ce597389f0 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -21,23 +21,23 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
s_("DiscordService|Discord Notifications")
end
- def description
+ def self.description
s_("DiscordService|Send notifications about project events to a Discord channel.")
end
- def self.to_param
- "discord"
- end
-
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
+ def self.to_param
+ "discord"
+ end
+
def default_channel_placeholder
s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)')
end
diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb
index f6a12c4bb1a..b59e504c98f 100644
--- a/app/models/integrations/drone_ci.rb
+++ b/app/models/integrations/drone_ci.rb
@@ -87,20 +87,20 @@ module Integrations
"gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}")
end
- def title
+ def self.title
'Drone'
end
- def description
+ def self.description
s_('ProjectService|Run CI/CD pipelines with Drone.')
end
- def self.to_param
- 'drone_ci'
+ def self.help
+ s_('ProjectService|Run CI/CD pipelines with Drone.')
end
- def help
- s_('ProjectService|Run CI/CD pipelines with Drone.')
+ def self.to_param
+ 'drone_ci'
end
override :hook_url
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index 144d1a07b04..77be8f5db45 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -39,11 +39,11 @@ module Integrations
recipients.split.grep(Devise.email_regexp).uniq(&:downcase)
end
- def title
+ def self.title
s_('EmailsOnPushService|Emails on push')
end
- def description
+ def self.description
s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.')
end
diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb
index 003c896704a..9d6f4c2a56c 100644
--- a/app/models/integrations/ewm.rb
+++ b/app/models/integrations/ewm.rb
@@ -10,15 +10,15 @@ module Integrations
@reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i
end
- def title
+ def self.title
'EWM'
end
- def description
+ def self.description
s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index acacab2528e..7408f86d231 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -11,24 +11,24 @@ module Integrations
help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') },
required: true
- def title
+ def self.title
s_('ExternalWikiService|External wiki')
end
- def description
+ def self.description
s_('ExternalWikiService|Link to an external wiki from the sidebar.')
end
- def self.to_param
- 'external_wiki'
- end
-
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
+ def self.to_param
+ 'external_wiki'
+ end
+
def sections
[
{
diff --git a/app/models/integrations/gitlab_slack_application.rb b/app/models/integrations/gitlab_slack_application.rb
index 2d520eaf7e7..d008a28a226 100644
--- a/app/models/integrations/gitlab_slack_application.rb
+++ b/app/models/integrations/gitlab_slack_application.rb
@@ -26,11 +26,11 @@ module Integrations
update(active: !!slack_integration)
end
- def title
+ def self.title
s_('Integrations|GitLab for Slack app')
end
- def description
+ def self.description
s_('Integrations|Enable slash commands and notifications for a Slack workspace.')
end
diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb
index 5389e8dfa81..746f68fdc4c 100644
--- a/app/models/integrations/google_play.rb
+++ b/app/models/integrations/google_play.rb
@@ -32,15 +32,15 @@ module Integrations
title: -> { s_('GooglePlayStore|Protected branches and tags only') },
checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') }
- def title
+ def self.title
s_('GooglePlay|Google Play')
end
- def description
+ def self.description
s_('GooglePlay|Use GitLab to build and release an app in Google Play.')
end
- def help
+ def self.help
variable_list = [
'<code>SUPPLY_PACKAGE_NAME</code>',
'<code>SUPPLY_JSON_KEY_DATA</code>'
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 6e4753470a3..6a9d603e6e5 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -17,11 +17,11 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
'Google Chat'
end
- def description
+ def self.description
'Send notifications from GitLab to a room in Google Chat.'
end
@@ -29,7 +29,7 @@ module Integrations
'hangouts_chat'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(_('How do I set up a Google Chat webhook?'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'),
target: '_blank', rel: 'noopener noreferrer')
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 559e48afd10..cc570e49e36 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -32,34 +32,32 @@ module Integrations
non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') },
required: true
- def title
+ def self.title
'Harbor'
end
- def description
+ def self.description
s_("HarborIntegration|Use Harbor as this project's container registry.")
end
- def help
+ def self.help
s_("HarborIntegration|After the Harbor integration is activated, global variables `$HARBOR_USERNAME`, `$HARBOR_HOST`, `$HARBOR_OCI`, `$HARBOR_PASSWORD`, `$HARBOR_URL` and `$HARBOR_PROJECT` will be created for CI/CD use.")
end
+ def self.to_param
+ name.demodulize.downcase
+ end
+
def hostname
Gitlab::Utils.parse_url(url).hostname
end
- class << self
- def to_param
- name.demodulize.downcase
- end
-
- def supported_events
- []
- end
+ def self.supported_events
+ []
+ end
- def supported_event_actions
- []
- end
+ def self.supported_event_actions
+ []
end
def test(*_args)
diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb
index a54946f074a..a1ce0877957 100644
--- a/app/models/integrations/irker.rb
+++ b/app/models/integrations/irker.rb
@@ -53,14 +53,31 @@ module Integrations
# in the UI or API.
prop_accessor :channels
- def title
+ def self.title
s_('IrkerService|irker (IRC gateway)')
end
- def description
+ def self.description
s_('IrkerService|Send update messages to an irker server.')
end
+ def self.help
+ docs_link = ActionController::Base.helpers.link_to(
+ _('Learn more.'),
+ Rails.application.routes.url_helpers.help_page_url(
+ 'user/project/integrations/irker',
+ anchor: 'set-up-an-irker-daemon'
+ ),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+
+ format(s_(
+ 'IrkerService|Send update messages to an irker server. ' \
+ 'Before you can use this, you need to set up the irker daemon. %{docs_link}'
+ ).html_safe, docs_link: docs_link.html_safe)
+ end
+
def self.to_param
'irker'
end
@@ -85,23 +102,6 @@ module Integrations
}
end
- def help
- docs_link = ActionController::Base.helpers.link_to(
- _('Learn more.'),
- Rails.application.routes.url_helpers.help_page_url(
- 'user/project/integrations/irker',
- anchor: 'set-up-an-irker-daemon'
- ),
- target: '_blank',
- rel: 'noopener noreferrer'
- )
-
- format(s_(
- 'IrkerService|Send update messages to an irker server. ' \
- 'Before you can use this, you need to set up the irker daemon. %{docs_link}'
- ).html_safe, docs_link: docs_link.html_safe)
- end
-
private
def get_channels
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index 0683c8408bc..a2f5667eaee 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -69,15 +69,15 @@ module Integrations
%w[push merge_request tag_push]
end
- def title
+ def self.title
'Jenkins'
end
- def description
+ def self.description
s_('Run CI/CD pipelines with Jenkins.')
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index f6e99454cb1..37735b956fc 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -191,9 +191,17 @@ module Integrations
end
end
- def help
+ def self.title
+ 'Jira'
+ end
+
+ def self.description
+ s_("JiraService|Use Jira as this project's issue tracker.")
+ end
+
+ def self.help
jira_doc_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe,
- url: help_page_path('integration/jira/index'))
+ url: Gitlab::Routing.url_helpers.help_page_path('integration/jira/index'))
format(
s_("JiraService|You must configure Jira before enabling this integration. " \
"%{jira_doc_link_start}Learn more.%{link_end}"),
@@ -201,14 +209,6 @@ module Integrations
link_end: '</a>'.html_safe)
end
- def title
- 'Jira'
- end
-
- def description
- s_("JiraService|Use Jira as this project's issue tracker.")
- end
-
def self.to_param
'jira'
end
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index 7e391b11d82..361ff4afce8 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -5,11 +5,11 @@ module Integrations
include SlackMattermostNotifier
include SlackMattermostFields
- def title
+ def self.title
_('Mattermost notifications')
end
- def description
+ def self.description
s_('Send notifications about project events to Mattermost channels.')
end
@@ -17,7 +17,7 @@ module Integrations
'mattermost'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index 73cddd163e0..9554dec4168 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -14,11 +14,11 @@ module Integrations
false
end
- def title
+ def self.title
s_('Integrations|Mattermost slash commands')
end
- def description
+ def self.description
s_('Integrations|Perform common tasks with slash commands.')
end
diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb
index 208172d6303..3a7c848d411 100644
--- a/app/models/integrations/microsoft_teams.rb
+++ b/app/models/integrations/microsoft_teams.rb
@@ -18,11 +18,11 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
'Microsoft Teams notifications'
end
- def description
+ def self.description
'Send notifications about project events to Microsoft Teams.'
end
@@ -30,7 +30,7 @@ module Integrations
'microsoft_teams'
end
- def help
+ def self.help
'<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html" target="_blank" rel="noopener noreferrer">How do I configure this integration?</a></p>'
end
diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb
index 2d8e26d409f..9c129ca727c 100644
--- a/app/models/integrations/mock_ci.rb
+++ b/app/models/integrations/mock_ci.rb
@@ -14,11 +14,11 @@ module Integrations
validates :mock_service_url, presence: true, public_url: true, if: :activated?
- def title
+ def self.title
'MockCI'
end
- def description
+ def self.description
'Mock an external CI'
end
diff --git a/app/models/integrations/mock_monitoring.rb b/app/models/integrations/mock_monitoring.rb
index 72bb292edaa..9e474078b28 100644
--- a/app/models/integrations/mock_monitoring.rb
+++ b/app/models/integrations/mock_monitoring.rb
@@ -2,11 +2,11 @@
module Integrations
class MockMonitoring < BaseMonitoring
- def title
+ def self.title
'Mock monitoring'
end
- def description
+ def self.description
'Mock monitoring service'
end
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index c0acb6c87b4..f027afe0381 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -29,11 +29,11 @@ module Integrations
validates :username, presence: true, if: :activated?
validates :token, presence: true, if: :activated?
- def title
+ def self.title
'Packagist'
end
- def description
+ def self.description
s_('Integrations|Keep your PHP dependencies updated on Packagist.')
end
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index 01efbc3e4a4..c7a93d48825 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -44,11 +44,11 @@ module Integrations
end
end
- def title
+ def self.title
_('Pipeline status emails')
end
- def description
+ def self.description
_('Email the pipeline status to a list of recipients.')
end
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index b3cbc988dd6..97e6e3e09d1 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -20,15 +20,15 @@ module Integrations
'automatically inspect. Leave blank to include all branches.')
end
- def title
+ def self.title
'Pivotal Tracker'
end
- def description
+ def self.description
s_('PivotalTrackerService|Add commit messages as comments to Pivotal Tracker stories.')
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index ff8d07a1b4c..de923bbbdd5 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -51,11 +51,11 @@ module Integrations
false
end
- def title
+ def self.title
'Prometheus'
end
- def description
+ def self.description
s_('PrometheusService|Monitor application health with Prometheus metrics and dashboards')
end
diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb
index 09e011023ed..36ff5189b0f 100644
--- a/app/models/integrations/pumble.rb
+++ b/app/models/integrations/pumble.rb
@@ -18,11 +18,11 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
'Pumble'
end
- def description
+ def self.description
s_("PumbleIntegration|Send notifications about project events to Pumble.")
end
@@ -30,7 +30,7 @@ module Integrations
'pumble'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pumble'),
diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb
index 2feae29f627..b2c4e06e71f 100644
--- a/app/models/integrations/pushover.rb
+++ b/app/models/integrations/pushover.rb
@@ -71,11 +71,11 @@ module Integrations
]
end
- def title
+ def self.title
'Pushover'
end
- def description
+ def self.description
s_('PushoverService|Get real-time notifications on your device.')
end
diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb
index bc2a64b0848..11eda7c69f7 100644
--- a/app/models/integrations/redmine.rb
+++ b/app/models/integrations/redmine.rb
@@ -6,15 +6,15 @@ module Integrations
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def title
+ def self.title
'Redmine'
end
- def description
+ def self.description
s_("IssueTracker|Use Redmine as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb
index 227fdca5c91..1d004356469 100644
--- a/app/models/integrations/shimo.rb
+++ b/app/models/integrations/shimo.rb
@@ -16,11 +16,11 @@ module Integrations
valid? && activated?
end
- def title
+ def self.title
s_('Shimo|Shimo')
end
- def description
+ def self.description
s_('Shimo|Link to a Shimo Workspace from the sidebar.')
end
diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb
index f70376e2f0d..9f9614a84fd 100644
--- a/app/models/integrations/slack.rb
+++ b/app/models/integrations/slack.rb
@@ -5,11 +5,11 @@ module Integrations
include SlackMattermostNotifier
include SlackMattermostFields
- def title
+ def self.title
'Slack notifications'
end
- def description
+ def self.description
'Send notifications about project events to Slack.'
end
diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb
index b209f37ee7c..c5ea6f22951 100644
--- a/app/models/integrations/slack_slash_commands.rb
+++ b/app/models/integrations/slack_slash_commands.rb
@@ -10,11 +10,11 @@ module Integrations
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
placeholder: ''
- def title
+ def self.title
'Slack slash commands'
end
- def description
+ def self.description
"Perform common operations in Slack."
end
diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb
index bf3f391564f..1b4ab152b1d 100644
--- a/app/models/integrations/squash_tm.rb
+++ b/app/models/integrations/squash_tm.rb
@@ -22,15 +22,15 @@ module Integrations
validates :token, length: { maximum: 255 }, allow_blank: true
end
- def title
+ def self.title
'Squash TM'
end
- def description
+ def self.description
s_("SquashTmIntegration|Update Squash TM requirements when GitLab issues are modified.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/squash_tm'),
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index 575c3b8a334..913242ef9ac 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -47,15 +47,15 @@ module Integrations
end
end
- def title
+ def self.title
'JetBrains TeamCity'
end
- def description
+ def self.description
s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.')
end
- def help
+ def self.help
s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.')
end
diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb
index 71fe6f8d6ef..8eb1a7ad0ea 100644
--- a/app/models/integrations/telegram.rb
+++ b/app/models/integrations/telegram.rb
@@ -38,11 +38,11 @@ module Integrations
before_validation :set_webhook
- def title
+ def self.title
'Telegram'
end
- def description
+ def self.description
s_("TelegramIntegration|Send notifications about project events to Telegram.")
end
@@ -50,7 +50,7 @@ module Integrations
'telegram'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
Rails.application.routes.url_helpers.help_page_url('user/project/integrations/telegram'),
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index 3b4bcfa28d3..6ee95c1173b 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -17,11 +17,11 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
'Unify Circuit'
end
- def description
+ def self.description
s_('Integrations|Send notifications about project events to Unify Circuit.')
end
@@ -29,7 +29,7 @@ module Integrations
'unify_circuit'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer'
s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 3ef8ab39352..5f8cc195544 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -17,11 +17,11 @@ module Integrations
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
- def title
+ def self.title
s_("WebexTeamsService|Webex Teams")
end
- def description
+ def self.description
s_("WebexTeamsService|Send notifications about project events to Webex Teams.")
end
@@ -29,7 +29,7 @@ module Integrations
'webex_teams'
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer'
s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index 15246a37aa7..932e588a829 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -14,15 +14,15 @@ module Integrations
@reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/
end
- def title
+ def self.title
'YouTrack'
end
- def description
+ def self.description
s_("IssueTracker|Use YouTrack as this project's issue tracker.")
end
- def help
+ def self.help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer'
s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index 58ec4abf30c..2aec0c1e871 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -57,18 +57,18 @@ module Integrations
data_fields.api_url ||= issues_tracker['api_url']
end
- def title
+ def self.title
'ZenTao'
end
- def description
+ def self.description
s_("ZentaoIntegration|Use ZenTao as this project's issue tracker.")
end
- def help
+ def self.help
s_("ZentaoIntegration|Before you enable this integration, you must configure ZenTao. For more details, read the %{link_start}ZenTao integration documentation%{link_end}.") % {
link_start: '<a href="%{url}" target="_blank" rel="noopener noreferrer">'
- .html_safe % { url: help_page_url('user/project/integrations/zentao') },
+ .html_safe % { url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/zentao') },
link_end: '</a>'.html_safe
}
end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index aac90c20b53..44f51f43998 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -49,10 +49,6 @@ class MergeRequestNoteableEntity < IssuableEntity
expose :can_update do |merge_request|
can?(current_user, :update_merge_request, merge_request)
end
-
- expose :can_approve do |merge_request|
- merge_request.eligible_for_approval_by?(current_user)
- end
end
expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request|
diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb
index a2307bfebf0..e0218ae087e 100644
--- a/app/services/projects/group_links/destroy_service.rb
+++ b/app/services/projects/group_links/destroy_service.rb
@@ -3,8 +3,10 @@
module Projects
module GroupLinks
class DestroyService < BaseService
- def execute(group_link)
- return false unless group_link
+ def execute(group_link, skip_authorization: false)
+ unless valid_to_destroy?(group_link, skip_authorization)
+ return ServiceResponse.error(message: 'Not found', reason: :not_found)
+ end
if group_link.project.private?
TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id)
@@ -12,20 +14,29 @@ module Projects
TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id)
end
- group_link.destroy.tap do |link|
- refresh_project_authorizations_asynchronously(link.project)
+ link = group_link.destroy
- # Until we compare the inconsistency rates of the new specialized worker and
- # the old approach, we still run AuthorizedProjectsWorker
- # but with some delay and lower urgency as a safety net.
- link.group.refresh_members_authorized_projects(
- priority: UserProjectAccessChangedService::LOW_PRIORITY
- )
- end
+ refresh_project_authorizations_asynchronously(link.project)
+
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ link.group.refresh_members_authorized_projects(
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
+
+ ServiceResponse.success(payload: { link: link })
end
private
+ def valid_to_destroy?(group_link, skip_authorization)
+ return false unless group_link
+ return true if skip_authorization
+
+ current_user.can?(:admin_project_group_link, group_link)
+ end
+
def refresh_project_authorizations_asynchronously(project)
AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
end
diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb
index 9b2565adaca..04f1552d929 100644
--- a/app/services/projects/group_links/update_service.rb
+++ b/app/services/projects/group_links/update_service.rb
@@ -10,15 +10,23 @@ module Projects
end
def execute(group_link_params)
+ return ServiceResponse.error(message: 'Not found', reason: :not_found) unless allowed_to_update?
+
group_link.update!(group_link_params)
refresh_authorizations if requires_authorization_refresh?(group_link_params)
+
+ ServiceResponse.success
end
private
attr_reader :group_link
+ def allowed_to_update?
+ current_user.can?(:admin_project_member, project)
+ end
+
def refresh_authorizations
AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index f1da5f37945..0bac595f0c4 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -11,7 +11,7 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork
def perform
ProjectGroupLink.expired.find_each do |link|
- Projects::GroupLinks::DestroyService.new(link.project, nil).execute(link)
+ Projects::GroupLinks::DestroyService.new(link.project, nil).execute(link, skip_authorization: true)
end
GroupGroupLink.expired.find_in_batches do |link_batch|
diff --git a/config/feature_flags/development/new_pipeline_graph.yml b/config/feature_flags/development/new_pipeline_graph.yml
new file mode 100644
index 00000000000..d3570980f63
--- /dev/null
+++ b/config/feature_flags/development/new_pipeline_graph.yml
@@ -0,0 +1,8 @@
+---
+name: new_pipeline_graph
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132462
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/426902
+milestone: '16.5'
+type: development
+group: group::ux paper cuts
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 50d8217bb74..623cc867022 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -5004,6 +5004,39 @@ Input type: `MarkAsSpamSnippetInput`
| <a id="mutationmarkasspamsnippeterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationmarkasspamsnippetsnippet"></a>`snippet` | [`Snippet`](#snippet) | Snippet after mutation. |
+### `Mutation.memberRoleCreate`
+
+WARNING:
+**Introduced** in 16.5.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `MemberRoleCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationmemberrolecreateadmingroupmember"></a>`adminGroupMember` | [`Boolean`](#boolean) | Permission to admin group members. |
+| <a id="mutationmemberrolecreateadminmergerequest"></a>`adminMergeRequest` | [`Boolean`](#boolean) | Permission to admin merge requests. |
+| <a id="mutationmemberrolecreateadminvulnerability"></a>`adminVulnerability` | [`Boolean`](#boolean) | Permission to admin vulnerability. |
+| <a id="mutationmemberrolecreatebaseaccesslevel"></a>`baseAccessLevel` | [`MemberAccessLevel!`](#memberaccesslevel) | Base access level for the custom role. |
+| <a id="mutationmemberrolecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationmemberrolecreatedescription"></a>`description` | [`String`](#string) | Description of the member role. |
+| <a id="mutationmemberrolecreategrouppath"></a>`groupPath` | [`ID!`](#id) | Group the member role to mutate is in. |
+| <a id="mutationmemberrolecreatemanageprojectaccesstokens"></a>`manageProjectAccessTokens` | [`Boolean`](#boolean) | Permission to admin project access tokens. |
+| <a id="mutationmemberrolecreatename"></a>`name` | [`String`](#string) | Name of the member role. |
+| <a id="mutationmemberrolecreatereadcode"></a>`readCode` | [`Boolean`](#boolean) | Permission to read code. |
+| <a id="mutationmemberrolecreatereaddependency"></a>`readDependency` | [`Boolean`](#boolean) | Permission to read dependency. |
+| <a id="mutationmemberrolecreatereadvulnerability"></a>`readVulnerability` | [`Boolean`](#boolean) | Permission to read vulnerability. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationmemberrolecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationmemberrolecreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| <a id="mutationmemberrolecreatememberrole"></a>`memberRole` | [`MemberRole`](#memberrole) | Updated member role. |
+
### `Mutation.memberRoleUpdate`
Input type: `MemberRoleUpdateInput`
@@ -19911,9 +19944,18 @@ Represents a member role.
| Name | Type | Description |
| ---- | ---- | ----------- |
+| <a id="memberroleadmingroupmember"></a>`adminGroupMember` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin group members. |
+| <a id="memberroleadminmergerequest"></a>`adminMergeRequest` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin merge requests. |
+| <a id="memberroleadminvulnerability"></a>`adminVulnerability` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin vulnerability. |
+| <a id="memberrolebaseaccesslevel"></a>`baseAccessLevel` **{warning-solid}** | [`AccessLevel!`](#accesslevel) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Base access level for the custom role. |
| <a id="memberroledescription"></a>`description` | [`String`](#string) | Description of the member role. |
+| <a id="memberroleenabledpermissions"></a>`enabledPermissions` **{warning-solid}** | [`[MemberRolePermission!]`](#memberrolepermission) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Array of all permissions enabled for the custom role. |
| <a id="memberroleid"></a>`id` | [`MemberRoleID!`](#memberroleid) | ID of the member role. |
+| <a id="memberrolemanageprojectaccesstokens"></a>`manageProjectAccessTokens` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin project access tokens. |
| <a id="memberrolename"></a>`name` | [`String!`](#string) | Name of the member role. |
+| <a id="memberrolereadcode"></a>`readCode` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to read code. |
+| <a id="memberrolereaddependency"></a>`readDependency` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to read dependency. |
+| <a id="memberrolereadvulnerability"></a>`readVulnerability` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to read vulnerability. |
### `MergeAccessLevel`
@@ -28843,6 +28885,20 @@ Name of access levels of a group or project member.
| <a id="memberaccesslevelnameowner"></a>`OWNER` | Owner access. |
| <a id="memberaccesslevelnamereporter"></a>`REPORTER` | Reporter access. |
+### `MemberRolePermission`
+
+Member role permission.
+
+| Value | Description |
+| ----- | ----------- |
+| <a id="memberrolepermissionadmin_group_member"></a>`ADMIN_GROUP_MEMBER` | Allows admin access to group members. |
+| <a id="memberrolepermissionadmin_merge_request"></a>`ADMIN_MERGE_REQUEST` | Allows admin access to the merge requests. |
+| <a id="memberrolepermissionadmin_vulnerability"></a>`ADMIN_VULNERABILITY` | Allows admin access to the vulnerability reports. |
+| <a id="memberrolepermissionmanage_project_access_tokens"></a>`MANAGE_PROJECT_ACCESS_TOKENS` | Allows manage access to the project access tokens. |
+| <a id="memberrolepermissionread_code"></a>`READ_CODE` | Allows read-only access to the source code. |
+| <a id="memberrolepermissionread_dependency"></a>`READ_DEPENDENCY` | Allows read-only access to the dependencies. |
+| <a id="memberrolepermissionread_vulnerability"></a>`READ_VULNERABILITY` | Allows read-only access to the vulnerability reports. |
+
### `MemberSort`
Values for sorting members.
diff --git a/doc/development/fe_guide/style/typescript.md b/doc/development/fe_guide/style/typescript.md
index 9497a0742e7..529459097b4 100644
--- a/doc/development/fe_guide/style/typescript.md
+++ b/doc/development/fe_guide/style/typescript.md
@@ -1,6 +1,7 @@
---
-stage: Create
-group: Editor Extensions
+type: reference, dev
+stage: none
+group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
@@ -28,27 +29,79 @@ The following GitLab projects use TypeScript:
- [`gitlab-language-server-for-code-suggestions`](https://gitlab.com/gitlab-org/editor-extensions/gitlab-language-server-for-code-suggestions)
- [`gitlab-org/cluster-integration/javascript-client`](https://gitlab.com/gitlab-org/cluster-integration/javascript-client)
-## Recommended configurations
+## Recommendations
-The [GitLab Workflow Extension](https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/blob/main) project is a good model
-for a project's TypeScript configuration. Consider copying the `.tsconfig` and `.eslintrc.json` from there.
+### Setup ESLint and TypeScript configuration
-- In `.tsconfig`, make sure [`"strict": true`](https://www.typescriptlang.org/tsconfig#strict) is set.
-- In `.eslintrc.json`, make sure that TypeScript-specific parsing and linting is placed in an `overrides` for `**/*.ts` files.
+When setting up a new TypeScript project, configure strict type-safety rules for
+ESLint and TypeScript. This ensures that the project remains as type-safe as possible.
-## Future plans
+The [GitLab Workflow Extension](https://gitlab.com/gitlab-org/gitlab-vscode-extension/)
+project is a good model for a TypeScript project's boilerplate and configuration.
+Consider copying the `tsconfig.json` and `.eslintrc.json` from there.
-- Shared ESLint configuration to reuse across TypeScript projects.
+For `tsconfig.json`:
+
+- Use [`"strict": true`](https://www.typescriptlang.org/tsconfig#strict).
+ This enforces the strongest type-checking capabilities in the project and
+ prohibits overriding type-safety.
+- Use [`"skipLibCheck": true`](https://www.typescriptlang.org/tsconfig#skipLibCheck).
+ This improves compile time by only checking references `.d.ts`
+ files as opposed to all `.d.ts` files in `node_modules`.
+
+For `.eslintrc.json` (or `.eslintrc.js`):
+
+- Make sure that TypeScript-specific parsing and linting are placed in an `overrides`
+ for `**/*.ts` files. This way, linting regular `.js` files
+ remains unaffected by the TypeScript-specific rules.
+- Extend from [`plugin:@typescript-eslint/recommended`](https://typescript-eslint.io/rules?supported-rules=recommended)
+ which has some very sensible defaults, such as:
+ - [`"@typescript-eslint/no-explicit-any": "error"`](https://typescript-eslint.io/rules/no-explicit-any/)
+ - [`"@typescript-eslint/no-unsafe-assignment": "error"`](https://typescript-eslint.io/rules/no-unsafe-assignment/)
+ - [`"@typescript-eslint/no-unsafe-return": "error"`](https://typescript-eslint.io/rules/no-unsafe-return)
+
+### Avoid `any`
+
+Avoid `any` at all costs. This should already be configured in the project's linter,
+but it's worth calling out here.
+
+Developers commonly resort to `any` when dealing with data structures that cross
+domain boundaries, such as handling HTTP responses or interacting with untyped
+libraries. This appears convenient at first. However, opting for a well-defined type (or using
+`unknown` and employing type narrowing through predicates) carries substantial benefits.
+
+```typescript
+// Bad :(
+function handleMessage(data: any) {
+ console.log("We don't know what data is. This could blow up!", data.special.stuff);
+}
+
+// Good :)
+function handleMessage(data: unknown) {
+ console.log("Sometimes it's okay that it remains unknown.", JSON.stringify(data));
+}
+
+// Also good :)
+function isFooMessage(data: unknown): data is { foo: string } {
+ return typeof data === 'object' && data && 'foo' in data;
+}
-## Recommended patterns
+function handleMessage(data: unknown) {
+ if (isFooMessage(data)) {
+ console.log("We know it's a foo now. This is safe!", data.foo);
+ }
+}
+```
### Avoid casting with `<>` or `as`
-Avoid casting with `<>` or `as` as much as possible. This circumvents Type safety. Consider using
+Avoid casting with `<>` or `as` as much as possible.
+
+Type casting explicitly circumvents type-safety. Consider using
[type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates).
```typescript
-// Bad
+// Bad :(
function handler(data: unknown) {
console.log((data as StuffContainer).stuff);
}
@@ -72,22 +125,34 @@ function handler(data: unknown) {
```
-### Prefer `interface` over `type` for new interfaces
+There's some rare cases this might be acceptable (consider
+[this test utility](https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/3ea8191ed066811caa4fb108713e7538b8d8def1/packages/vscode-extension-web-ide/test-utils/createFakePartial.ts#L1)). However, 99% of the
+time, there's a better way.
+
+### Prefer `interface` over `type` for new structures
-Prefer interface over type declaration when describing structures.
+Prefer declaring a new `interface` over declaring a new `type` alias when defining new structures.
+
+Interfaces and type aliases have a lot of cross-over, but only interfaces can be used
+with the `implements` keyword. A class is not able to `implement` a `type` (only an `interface`),
+so using `type` would restrict the usability of the structure.
```typescript
-// Bad
+// Bad :(
type Fooer = {
foo: () => string;
}
-// Good
+// Good :)
interface Fooer {
foo: () => string;
}
```
+From the [TypeScript guide](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces):
+
+> If you would like a heuristic, use `interface` until you need to use features from `type`.
+
### Use `type` to define aliases for existing types
Use type to define aliases for existing types, classes or interfaces. Use
@@ -101,21 +166,21 @@ interface Config = {
isBad: boolean;
}
-// Bad
+// Bad :(
type PartialConfig = {
foo?: string;
isBad?: boolean;
}
-// Good
+// Good :)
type PartialConfig = Partial<Config>;
```
### Use union types to improve inference
```typescript
-// Bad
+// Bad :(
interface Foo { type: string }
interface FooBar extends Foo { bar: string }
interface FooZed extends Foo { zed: string }
@@ -128,7 +193,7 @@ const doThing = (foo: Foo) => {
}
// Good :)
-interface FooBar { type: 'bar', bar: string };
+interface FooBar { type: 'bar', bar: string }
interface FooZed { type: 'zed', zed: string }
type Foo = FooBar | FooZed;
@@ -140,6 +205,10 @@ const doThing = (foo: Foo) => {
}
```
+## Future plans
+
+- Shared ESLint configuration to reuse across TypeScript projects.
+
## Related topics
- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html)
diff --git a/doc/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.md b/doc/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.md
index d68e5565775..d9f45a2d93e 100644
--- a/doc/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.md
+++ b/doc/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.md
@@ -14,7 +14,7 @@ Internal events are using a tool called Snowplow under the hood. To develop and
| Snowplow Micro | Yes | Yes | Yes | No | No |
For local development you will have to either [setup a local event collector](#setup-local-event-collector) or [configure a remote event collector](#configure-a-remote-event-collector).
-We recommend the local setup when actively developing new events.
+We recommend using the local setup together with the [internal events monitor](#internal-events-monitor) when actively developing new events.
## Setup local event collector
@@ -68,6 +68,57 @@ You can configure your self-managed GitLab instance to use a custom Snowplow col
1. Select **Save changes**.
+## Internal Events Monitor
+
+<div class="video-fallback">
+ Watch the demo video about the <a href="https://www.youtube.com/watch?v=R7vT-VEzZOI">Internal Events Tracking Monitor</a>
+</div>
+<figure class="video_container">
+ <iframe src="https://www.youtube-nocookie.com/embed/R7vT-VEzZOI" frameborder="0" allowfullscreen="true"> </iframe>
+</figure>
+
+To understand how events are triggered and metrics are updated while you use the Rails app locally or `rails console`,
+you can use the monitor.
+
+Start the monitor and list one or more events that you would like to monitor. In this example we would like to monitor `i_code_review_user_create_mr`.
+
+```shell
+rails runner scripts/internal_events/monitor.rb i_code_review_user_create_mr
+```
+
+The monitor shows two tables. The top table lists all the metrics that are defined on the `i_code_review_user_create_mr` event.
+The second right-most column shows the value of each metric when the monitor was started and the right most column shows the current value of each metric.
+The bottom table has a list selected properties of all Snowplow events that matches the event name.
+
+If a new `i_code_review_user_create_mr` event is fired, the metrics values will get updated and a new event will appear in the `SNOWPLOW EVENTS` table.
+
+The monitor looks like below.
+
+```plaintext
+Updated at 2023-10-11 10:17:59 UTC
+Monitored events: i_code_review_user_create_mr
+
++--------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| RELEVANT METRICS |
++-----------------------------------------------------------------------------+------------------------------+-----------------------+---------------+---------------+
+| Key Path | Monitored Events | Instrumentation Class | Initial Value | Current Value |
++-----------------------------------------------------------------------------+------------------------------+-----------------------+---------------+---------------+
+| counts_monthly.aggregated_metrics.code_review_category_monthly_active_users | i_code_review_user_create_mr | AggregatedMetric | 13 | 14 |
+| counts_monthly.aggregated_metrics.code_review_group_monthly_active_users | i_code_review_user_create_mr | AggregatedMetric | 13 | 14 |
+| counts_weekly.aggregated_metrics.code_review_category_monthly_active_users | i_code_review_user_create_mr | AggregatedMetric | 0 | 1 |
+| counts_weekly.aggregated_metrics.code_review_group_monthly_active_users | i_code_review_user_create_mr | AggregatedMetric | 0 | 1 |
+| redis_hll_counters.code_review.i_code_review_user_create_mr_monthly | i_code_review_user_create_mr | RedisHLLMetric | 8 | 9 |
+| redis_hll_counters.code_review.i_code_review_user_create_mr_weekly | i_code_review_user_create_mr | RedisHLLMetric | 0 | 1 |
++-----------------------------------------------------------------------------+------------------------------+-----------------------+---------------+---------------+
++---------------------------------------------------------------------------------------------------------+
+| SNOWPLOW EVENTS |
++------------------------------+--------------------------+---------+--------------+------------+---------+
+| Event Name | Collector Timestamp | user_id | namespace_id | project_id | plan |
++------------------------------+--------------------------+---------+--------------+------------+---------+
+| i_code_review_user_create_mr | 2023-10-11T10:17:15.504Z | 29 | 93 | | default |
++------------------------------+--------------------------+---------+--------------+------------+---------+
+```
+
## Snowplow Analytics Debugger Chrome Extension
[Snowplow Analytics Debugger](https://chrome.google.com/webstore/detail/snowplow-analytics-debugg/jbnlcgeengmijcghameodeaenefieedm) is a browser extension for testing frontend events.
diff --git a/doc/security/unlock_user.md b/doc/security/unlock_user.md
index fe10274ce5a..8184bdfdd8c 100644
--- a/doc/security/unlock_user.md
+++ b/doc/security/unlock_user.md
@@ -18,10 +18,14 @@ By default, users are locked after 10 failed sign-in attempts. These users remai
In GitLab 16.5 and later, administrators can [use the API](../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls) to configure:
-- The number of failed sign-in attempts that locks a user.
-- The time period in minutes that the locked user is locked for, after the maximum number of failed sign-in attempts is reached.
+- The number of failed sign-in attempts that locks a user (`max_login_attempts`).
+- The time period in minutes that the locked user is locked for, after the maximum number of failed sign-in attempts is reached (`failed_login_attempts_unlock_period_in_minutes`).
-For example, an administrator can configure that five failed sign-in attempts locks a user, and that user will be locked for 60 minutes.
+For example, an administrator can configure that five failed sign-in attempts locks a user, and that user will be locked for 60 minutes, with the following API call:
+
+```shell
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/application/settings?max_login_attempts=5&failed_login_attempts_unlock_period_in_minutes=60"
+```
## GitLab.com users
diff --git a/doc/user/custom_roles.md b/doc/user/custom_roles.md
index a13c45306ad..f9978d5065c 100644
--- a/doc/user/custom_roles.md
+++ b/doc/user/custom_roles.md
@@ -147,14 +147,14 @@ To do this, you can either remove the custom role from all group members with th
### Remove a custom role from a group member
To remove a custom role from a group member, use the [Group and Project Members API endpoint](../api/members.md#edit-a-member-of-a-group-or-project)
-and pass an empty `member_role_id` value.
+and pass an empty `member_role_id` value:
```shell
# to update a project membership
-curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer <your_access_token>" --data '{"member_role_id": "", "access_level": 10}' "https://gitlab.example.com/api/v4/projects/<project_id>/members/<user_id>"
+curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer <your_access_token>" --data '{"member_role_id": null, "access_level": 10}' "https://gitlab.example.com/api/v4/projects/<project_id>/members/<user_id>"
# to update a group membership
-curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer <your_access_token>" --data '{"member_role_id": "", "access_level": 10}' "https://gitlab.example.com/api/v4/groups/<group_id>/members/<user_id>"
+curl --request PUT --header "Content-Type: application/json" --header "Authorization: Bearer <your_access_token>" --data '{"member_role_id": null, "access_level": 10}' "https://gitlab.example.com/api/v4/groups/<group_id>/members/<user_id>"
```
### Remove a group member with a custom role from the group
diff --git a/doc/user/product_analytics/index.md b/doc/user/product_analytics/index.md
index ad7367e22c9..dec16fcab76 100644
--- a/doc/user/product_analytics/index.md
+++ b/doc/user/product_analytics/index.md
@@ -105,7 +105,7 @@ Prerequisites:
To instrument code to collect data, use one or more of the existing SDKs:
-- [Browser SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-browser)
+- [Browser SDK](instrumentation/browser_sdk.md)
- [Ruby SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-rb)
- [Python SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-python)
- [Node SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-node)
diff --git a/doc/user/product_analytics/instrumentation/browser_sdk.md b/doc/user/product_analytics/instrumentation/browser_sdk.md
new file mode 100644
index 00000000000..1cc74d037b6
--- /dev/null
+++ b/doc/user/product_analytics/instrumentation/browser_sdk.md
@@ -0,0 +1,251 @@
+---
+stage: Analyze
+group: Analytics Instrumentation
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Browser SDK
+
+This SDK is for instrumenting web sites and applications to send data for the GitLab [product analytics functionality](../index.md).
+
+## How to use the Browser-SDK
+
+### Using the NPM package
+
+Add the NPM package to your package JSON using your preferred package manager:
+
+```shell
+yarn add @gitlab/application-sdk-browser
+```
+
+OR
+
+```shell
+npm i @gitlab/application-sdk-browser
+```
+
+Then for browser usage you can import the client SDK:
+
+```javascript
+import { glClientSDK } from '@gitlab/application-sdk-browser';
+
+this.glClient = glClientSDK({ appId, host });
+```
+
+### Using the script directly
+
+Add the script to the page and assign the client SDK to `window`:
+
+```html
+<script src="https://unpkg.com/@gitlab/application-sdk-browser/dist/gl-sdk.min.js"></script>
+<script>
+ window.glClient = window.glSDK.glClientSDK({
+ appId: 'YOUR_APP_ID',
+ host: 'YOUR_HOST',
+ });
+</script>
+```
+
+You can use a specific version of the SDK like this:
+
+```html
+<script src="https://unpkg.com/@gitlab/application-sdk-browser@0.2.5/dist/gl-sdk.min.js"></script>
+```
+
+## Browser-SDK initialization options
+
+Apart from `appId` and `host`, the options below allow you to configure the Browser SDK.
+
+```typescript
+interface GitLabClientSDKOptions {
+ appId: string;
+ host: string;
+ hasCookieConsent?: boolean;
+ respectGlobalPrivacyControl?: boolean;
+ trackerId?: string;
+ pagePingTracking?:
+ | boolean
+ | {
+ minimumVisitLength?: number;
+ heartbeatDelay?: number;
+ };
+ plugins?: AllowedPlugins;
+}
+```
+
+| Option | Description |
+| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `appId` | This is the ID given by the GitLab Project Analytics setup guide. This is used to make sure your data is sent to your analytics instance. |
+| `host` | This is the GitLab Project Analytics instance that is given by the setup guide. |
+| `hasCookieConsent` | To use cookies to identify unique users and record their full IP address. This is set to `false` by default. When `false`, users will be considered anonymous users. No cookies or other storage mechanisms will be used to identify users. |
+| `respectGlobalPrivacyControl` | To respect the user's [GPC](https://globalprivacycontrol.org/) configuration to permit or refuse tracking. This is set to `true` by default. When `false`, events will be emitted regardless of user configuration. |
+| `trackerId` | The `trackerId` is used to differentiate between multiple trackers running on the same page or application, as each tracker instance can be configured differently to capture different sets of data. This identifier helps ensure that the data sent to the collector is correctly associated with the correct tracker configuration. `Default trackerId value is set as gitlab`. |
+| `pagePingTracking` | Page ping is a feature that allows you to `track user engagement on your website or application by sending periodic events while a user is actively browsing a page.` Page pings provide valuable insight into how users interact with your content, such as how long they spend on a page, which sections they are viewing, and if they are scrolling or not. `pagePingTracking` can be boolean or an object. If true it enables page ping with default options. if false, it will not enable page ping tracking. it can also be an object containing two options : `minimumVisitLength` - The minimum time that must have elapsed before first heartbeat. `heartbeatDelay` - The interval at which the callback is fired. |
+| `plugins` | Specify which plugins to enable or disable. By default all plugins are enabled. |
+
+### Plugins
+
+- `Client Hints`: It is an alternative the tracking the User Agent, which is particularly useful in those browsers which are freezing the User Agent string.
+Enabling this plugin will automatically capture the following context:
+
+| Context | Example |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
+| [iglu:org.ietf/http_client_hints/jsonschema/1-0-0](https://github.com/snowplow/iglu-central/blob/master/schemas/org.ietf/http_client_hints/jsonschema/1-0-0) | `{"isMobile" : false, "brands" : [{"brand" : "Google Chrome", version : "89"}, {"brand" : "Chromium", version : "89"}]}` |
+
+- `Link Click Tracking`: With this plugin, the tracker will add click event listeners to all link elements. Link clicks are tracked as self-describing events. Each link-click event captures the link’s href attribute. The event also has fields for the link’s ID, classes, and target (where the linked document is opened, such as a new tab or new window).
+
+- `Performance Timing`: It collects performance-related data from a user's browser using the `Navigation Timing API`. This API provides detailed information about the various stages of loading a web page, such as domain lookup, connection time, content download, and rendering times. This plugin helps to gather insights into how well website performs for users, identify potential performance bottlenecks, and improve the overall user experience.
+
+- `Error Tracking`: It helps to capture and track errors that occur on website or application. By monitoring these errors, one can gain insights into potential issues with code or third-party libraries, which can help to improve the overall user experience and maintain the quality of website or application.
+
+`By default all the plugins are enabled`. These plugins can be enabled or disabled through the `plugins` object:
+
+```typescript
+const tracker = glClientSDK({
+ ...options,
+ plugins: {
+ clientHints: true,
+ linkTracking: true,
+ performanceTiming: true,
+ errorTracking: true,
+ },
+});
+```
+
+## Methods
+
+### `identify`
+
+Used to associate a user and their attributes with the session and tracking events.
+
+```javascript
+glClient.identify(userId, userAttributes);
+```
+
+| Property | Type | Description |
+| :--------------- | :-------------------------- | :---------------------------------------------------------------------------- |
+| `userId` | `String` | The user identifier your application users to identify individual users. |
+| `userAttributes` | `Object`/`Null`/`undefined` | The user attributes that need to be added to the session and tracking events. |
+
+### `page`
+
+Used to trigger a pageview event.
+
+```javascript
+glClient.page(eventAttributes);
+```
+
+| Property | Type | Description |
+| :---------------- | :-------------------------- | :---------------------------------------------------------------- |
+| `eventAttributes` | `Object`/`Null`/`undefined` | The event attributes that need to be added to the pageview event. |
+
+### `track`
+
+Used to trigger a custom event.
+
+```javascript
+glClient.track(eventName, eventAttributes);
+```
+
+| Property | Type | Description |
+| :---------------- | :-------------------------- | :--------------------------------------------------------------- |
+| `eventName` | `String` | The name of the custom event. |
+| `eventAttributes` | `Object`/`Null`/`undefined` | The event attributes that need to be added to the tracked event. |
+
+### refreshLinkClickTracking
+
+enableLinkClickTracking only tracks clicks on links which exist when the page has loaded. If new links can be added to the page after then which you wish to track, just use refreshLinkClickTracking.
+
+```javascript
+glClient.refreshLinkClickTracking();
+```
+
+### `trackError`
+
+NOTE:
+While `trackError` is supported on the Browser SDK the resulting events are currently not yet used or available anywhere.
+
+Used to capture errors. This works only when the `errorTracking` plugin is enabled. As mentioned in [Plugins](#plugins) section, By default it is enabled.
+
+```javascript
+glClient.trackError(eventAttributes);
+```
+
+For example, `trackError` can be used in `try...catch` like below:
+
+```javascript
+try {
+ // Call the function that throws an error
+ throwError();
+} catch (error) {
+ glClient.trackError({
+ message: error.message, // "This is a custom error"
+ filename: error.fileName || 'unknown', // The file in which the error occurred (e.g., "index.html")
+ lineno: error.lineNumber || 0, // The line number where the error occurred (e.g., 2)
+ colno: error.columnNumber || 0, // The column number where the error occurred (e.g., 6)
+ error: error, // The Error object itself
+ });
+}
+```
+
+| Property | Type | Description |
+| :---------------- | :------- | :------------------------------------------------------------------------------------------------------------------- |
+| `eventAttributes` | `Object` | The event attributes that need to be added to the tracked event. `messeage` is a mandatory key in `eventAttributes`. |
+
+### `addCookieConsent`
+
+`addCookieConsent` is used to allow tracking of user identifiers via cookies. By default `hasCookieConsent` is false and no user identifiers are passed. To enable tracking of user identifiers call the `addCookieConsent` method. This is not needed if you intialised the Browser SDK with `hasCookieConsent` set to true.
+
+```javascript
+glClient.addCookieConsent();
+```
+
+### setCustomUrl
+
+Used to set a custom URL for tracking.
+
+```javascript
+glClient.setCustomUrl(url);
+```
+
+| Property | Type | Description |
+| :------- | :------- | :------------------------------------------------ |
+| `url` | `String` | The custom URL that you want to set for tracking. |
+
+### setReferrerUrl
+
+Used to set a referrer URL for tracking.
+
+```javascript
+glClient.setReferrerUrl(url);
+```
+
+| Property | Type | Description |
+| :------- | :------- | :-------------------------------------------------- |
+| `url` | `String` | The referrer URL that you want to set for tracking. |
+
+### setDocumentTitle
+
+Used to override document title.
+
+```javascript
+glClient.setDocumentTitle(title);
+```
+
+| Property | Type | Description |
+| :------- | :------- | :--------------------------------- |
+| `title` | `String` | The document title you want to set |
+
+## Contribute
+
+Want to contribute to Browser-SDK? follow [contributing guide](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-js/-/blob/main/docs/Contributing.md).
+
+## Troubleshooting
+
+If the Browser SDK is not sending events or is behaving in an unexpected way, take the following actions:
+
+- Verify that the appId and host values in the options object are correct.
+- Check if any browser privacy settings, extensions, or ad blockers are interfering with the Browser SDK.
+
+For more information and assistance, consult the [Snowplow documentation](https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/browser-tracker/browser-tracker-v3-reference/)
+or contact the [Analytics Instrumentation](https://about.gitlab.com/handbook/engineering/development/analytics/analytics-instrumentation/#team-members) team.
diff --git a/doc/user/product_analytics/instrumentation/index.md b/doc/user/product_analytics/instrumentation/index.md
new file mode 100644
index 00000000000..f909a01ff59
--- /dev/null
+++ b/doc/user/product_analytics/instrumentation/index.md
@@ -0,0 +1,15 @@
+---
+stage: Analyze
+group: Analytics Instrumentation
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Instrumentation
+
+To instrument an application to send events to GitLab product analytics you can use one of the following language and platform specific tracking SDKs:
+
+- [Browser SDK](browser_sdk.md)
+- [Ruby SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-rb)
+- [Python SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-python)
+- [Node SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-node)
+- [.NET SDK](https://gitlab.com/gitlab-org/analytics-section/product-analytics/gl-application-sdk-dotnet)
diff --git a/doc/user/project/merge_requests/revert_changes.md b/doc/user/project/merge_requests/revert_changes.md
index cc95b63e5a8..4476ec8c670 100644
--- a/doc/user/project/merge_requests/revert_changes.md
+++ b/doc/user/project/merge_requests/revert_changes.md
@@ -25,9 +25,8 @@ Prerequisites:
- You must have a role in the project that allows you to edit merge requests, and add
code to the repository.
- Your project must use the [merge method](methods/index.md#fast-forward-merge) **Merge Commit**,
- which is set in the project's **Settings > General > Merge request**. Fast-forwarded commits
- can't be reverted from the GitLab UI, but the individual commits can
- [still be reverted](#revert-a-commit).
+ which is set in the project's **Settings > Merge requests**. You can't revert
+ fast-forwarded commits from the GitLab UI.
To do this:
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index ac28effea43..1f0b608b67c 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -792,7 +792,12 @@ module API
not_found!('Group Link') unless link
destroy_conditionally!(link) do
- ::Projects::GroupLinks::DestroyService.new(user_project, current_user).execute(link)
+ result = ::Projects::GroupLinks::DestroyService.new(user_project, current_user).execute(link)
+
+ if result.error?
+ status = :not_found if result.reason == :not_found
+ render_api_error!(result.message, status)
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb
index c9ed4720e83..23dafc21392 100644
--- a/lib/gitlab/project_template.rb
+++ b/lib/gitlab/project_template.rb
@@ -60,14 +60,14 @@ module Gitlab
ProjectTemplate.new('dotnetcore', '.NET Core', _('A .NET Core console application template, customizable for any .NET Core project'), 'https://gitlab.com/gitlab-org/project-templates/dotnetcore', 'illustrations/third-party-logos/dotnet.svg'),
ProjectTemplate.new('android', 'Android', _('A ready-to-go template for use with Android apps'), 'https://gitlab.com/gitlab-org/project-templates/android', 'illustrations/logos/android.svg'),
ProjectTemplate.new('gomicro', 'Go Micro', _('Go Micro is a framework for micro service development'), 'https://gitlab.com/gitlab-org/project-templates/go-micro', 'illustrations/logos/gomicro.svg'),
- ProjectTemplate.new('bridgetown', 'Pages/Bridgetown', _('Everything you need to create a GitLab Pages site using Bridgetown'), 'https://gitlab.com/gitlab-org/project-templates/bridgetown'),
+ ProjectTemplate.new('bridgetown', 'Pages/Bridgetown', _('Everything you need to create a GitLab Pages site using Bridgetown'), 'https://gitlab.com/pages/bridgetown'),
ProjectTemplate.new('gatsby', 'Pages/Gatsby', _('Everything you need to create a GitLab Pages site using Gatsby'), 'https://gitlab.com/pages/gatsby', 'illustrations/third-party-logos/gatsby.svg'),
ProjectTemplate.new('hugo', 'Pages/Hugo', _('Everything you need to create a GitLab Pages site using Hugo'), 'https://gitlab.com/pages/hugo', 'illustrations/logos/hugo.svg'),
ProjectTemplate.new('pelican', 'Pages/Pelican', _('Everything you need to create a GitLab Pages site using Pelican'), 'https://gitlab.com/pages/pelican', 'illustrations/third-party-logos/pelican.svg'),
ProjectTemplate.new('jekyll', 'Pages/Jekyll', _('Everything you need to create a GitLab Pages site using Jekyll'), 'https://gitlab.com/pages/jekyll', 'illustrations/logos/jekyll.svg'),
ProjectTemplate.new('plainhtml', 'Pages/Plain HTML', _('Everything you need to create a GitLab Pages site using plain HTML'), 'https://gitlab.com/pages/plain-html'),
ProjectTemplate.new('hexo', 'Pages/Hexo', _('Everything you need to create a GitLab Pages site using Hexo'), 'https://gitlab.com/pages/hexo', 'illustrations/logos/hexo.svg'),
- ProjectTemplate.new('middleman', 'Pages/Middleman', _('Everything you need to create a GitLab Pages site using Middleman'), 'https://gitlab.com/gitlab-org/project-templates/middleman', 'illustrations/logos/middleman.svg'),
+ ProjectTemplate.new('middleman', 'Pages/Middleman', _('Everything you need to create a GitLab Pages site using Middleman'), 'https://gitlab.com/pages/middleman', 'illustrations/logos/middleman.svg'),
ProjectTemplate.new('gitpod_spring_petclinic', 'Gitpod/Spring Petclinic', _('A Gitpod configured Webapplication in Spring and Java'), 'https://gitlab.com/gitlab-org/project-templates/gitpod-spring-petclinic', 'illustrations/logos/gitpod.svg'),
ProjectTemplate.new('nfhugo', 'Netlify/Hugo', _('A Hugo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features'), 'https://gitlab.com/pages/nfhugo', 'illustrations/logos/netlify.svg'),
ProjectTemplate.new('nfjekyll', 'Netlify/Jekyll', _('A Jekyll site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features'), 'https://gitlab.com/pages/nfjekyll', 'illustrations/logos/netlify.svg'),
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7fff677c22c..f1a1c2c5971 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14422,6 +14422,9 @@ msgstr ""
msgid "Creating epic"
msgstr ""
+msgid "Creation of member role is allowed only for root groups"
+msgstr ""
+
msgid "Creator"
msgstr ""
@@ -45903,6 +45906,9 @@ msgstr ""
msgid "StatusCheck|Target branch"
msgstr ""
+msgid "StatusCheck|URL parameters are hidden for security reasons. For details of URL parameters, see the configuration for the status check service."
+msgstr ""
+
msgid "StatusCheck|Update status check"
msgstr ""
@@ -47865,6 +47871,9 @@ msgstr ""
msgid "The project was successfully imported."
msgstr ""
+msgid "The project-group link could not be removed."
+msgstr ""
+
msgid "The related CI build failed."
msgstr ""
diff --git a/qa/lib/gitlab/page/subscriptions/new.rb b/qa/lib/gitlab/page/subscriptions/new.rb
index 95e5028f985..739efeed898 100644
--- a/qa/lib/gitlab/page/subscriptions/new.rb
+++ b/qa/lib/gitlab/page/subscriptions/new.rb
@@ -40,6 +40,21 @@ module Gitlab
# Order Summary
div :selected_plan
div :total_amount
+
+ # Alerts
+ div :lock_competition_error, text: /Operation failed due to a lock competition, please retry later./
+
+ def purchase
+ ::QA::Support::Retrier.retry_until(
+ max_duration: 60,
+ sleep_interval: 10,
+ message: 'Expected no Zuora lock competition error'
+ ) do
+ confirm_purchase
+ ::QA::Support::WaitForRequests.wait_for_requests
+ !lock_competition_error?
+ end
+ end
end
end
end
diff --git a/qa/lib/gitlab/page/subscriptions/new.stub.rb b/qa/lib/gitlab/page/subscriptions/new.stub.rb
index a7f5d689838..e660d198478 100644
--- a/qa/lib/gitlab/page/subscriptions/new.stub.rb
+++ b/qa/lib/gitlab/page/subscriptions/new.stub.rb
@@ -621,6 +621,30 @@ module Gitlab
def total_amount?
# This is a stub, used for indexing. The method is dynamically generated.
end
+
+ # @note Defined as +div :lock_competition_error+
+ # @return [String] The text content or value of +lock_competition_error+
+ def lock_competition_error
+ # This is a stub, used for indexing. The method is dynamically generated.
+ end
+
+ # @example
+ # Gitlab::Page::Subscriptions::New.perform do |new|
+ # expect(new.lock_competition_error_element).to exist
+ # end
+ # @return [Watir::Div] The raw +Div+ element
+ def lock_competition_error_element
+ # This is a stub, used for indexing. The method is dynamically generated.
+ end
+
+ # @example
+ # Gitlab::Page::Subscriptions::New.perform do |new|
+ # expect(new).to be_lock_competition_error
+ # end
+ # @return [Boolean] true if the +lock_competition_error+ element is present on the page
+ def lock_competition_error?
+ # This is a stub, used for indexing. The method is dynamically generated.
+ end
end
end
end
diff --git a/qa/qa/flow/purchase.rb b/qa/qa/flow/purchase.rb
index 2eee15b874c..76b1b2c7313 100644
--- a/qa/qa/flow/purchase.rb
+++ b/qa/qa/flow/purchase.rb
@@ -19,7 +19,7 @@ module QA
fill_in_customer_info
fill_in_payment_info
- new_subscription.confirm_purchase
+ new_subscription.purchase
end
end
@@ -37,7 +37,7 @@ module QA
fill_in_customer_info
fill_in_payment_info
- ci_minutes.confirm_purchase
+ ci_minutes.purchase
end
end
@@ -59,7 +59,7 @@ module QA
fill_in_customer_info
fill_in_payment_info
- storage.confirm_purchase
+ storage.purchase
end
end
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index 2075dd3e7a7..4510e9e646e 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -30,24 +30,29 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access
end
let(:expiry_date) { 1.month.from_now.to_date }
+ let(:group_access) { Gitlab::Access::GUEST }
- before do
- travel_to Time.now.utc.beginning_of_day
-
+ subject(:update_link) do
put(
:update,
params: {
namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: link.id,
- group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date }
+ group_link: { group_access: group_access, expires_at: expiry_date }
},
format: :json
)
end
+ before do
+ travel_to Time.now.utc.beginning_of_day
+ end
+
context 'when `expires_at` is set' do
it 'returns correct json response' do
+ update_link
+
expect(json_response).to eq({ "expires_in" => controller.helpers.time_ago_with_tooltip(expiry_date), "expires_soon" => false })
end
end
@@ -56,27 +61,41 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access
let(:expiry_date) { nil }
it 'returns empty json response' do
+ update_link
+
expect(json_response).to be_empty
end
end
+
+ it "returns an error when link is not updated" do
+ allow(::Projects::GroupLinks::UpdateService).to receive_message_chain(:new, :execute)
+ .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found))
+
+ update_link
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not Found')
+ end
end
describe '#destroy' do
let(:group_owner) { create(:user) }
+ let(:group_access) { Gitlab::Access::DEVELOPER }
+ let(:format) { :html }
- let(:link) do
- create(:project_group_link, project: project, group: group, group_access: Gitlab::Access::DEVELOPER)
+ let!(:link) do
+ create(:project_group_link, project: project, group: group, group_access: group_access)
end
subject(:destroy_link) do
post(:destroy, params: { namespace_id: project.namespace.to_param,
project_id: project.to_param,
- id: link.id })
+ id: link.id }, format: format)
end
shared_examples 'success response' do
it 'deletes the project group link' do
- destroy_link
+ expect { destroy_link }.to change { project.reload.project_group_links.count }
expect(response).to redirect_to(project_project_members_path(project))
expect(response).to have_gitlab_http_status(:found)
@@ -119,6 +138,27 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access
end
it_behaves_like 'success response'
+
+ it "returns an error when link is not destroyed" do
+ allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute)
+ .and_return(ServiceResponse.error(message: 'The error message'))
+
+ expect { destroy_link }.not_to change { project.reload.project_group_links.count }
+ expect(flash[:alert]).to eq('The project-group link could not be removed.')
+ end
+
+ context 'when format is js' do
+ let(:format) { :js }
+
+ it "returns an error when link is not destroyed" do
+ allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute)
+ .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found))
+
+ expect { destroy_link }.not_to change { project.reload.project_group_links.count }
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not Found')
+ end
+ end
end
context 'when user is not a project maintainer' do
diff --git a/spec/fixtures/api/schemas/entities/merge_request_noteable.json b/spec/fixtures/api/schemas/entities/merge_request_noteable.json
index 4b790a2c34b..6f3c29b16e9 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_noteable.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_noteable.json
@@ -24,13 +24,11 @@
"type": "object",
"required": [
"can_create_note",
- "can_update",
- "can_approve"
+ "can_update"
],
"properties": {
"can_create_note": { "type": "boolean" },
- "can_update": { "type": "boolean" },
- "can_approve": { "type": "boolean" }
+ "can_update": { "type": "boolean" }
},
"additionalProperties": false
},
diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js
index a2591635cf9..2f057af8a7d 100644
--- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js
@@ -1,13 +1,18 @@
import { GlDisclosureDropdown } from '@gitlab/ui';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue';
import { mockTracking } from 'helpers/tracking_helper';
+import userCanApproveQuery from '~/batch_comments/queries/can_approve.query.graphql';
jest.mock('~/autosave');
+Vue.use(VueApollo);
Vue.use(Vuex);
let wrapper;
@@ -17,6 +22,26 @@ let trackingSpy;
function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) {
publishReview = jest.fn();
trackingSpy = mockTracking(undefined, null, jest.spyOn);
+ const requestHandlers = [
+ [
+ userCanApproveQuery,
+ () =>
+ Promise.resolve({
+ data: {
+ project: {
+ id: 1,
+ mergeRequest: {
+ id: 1,
+ userPermissions: {
+ canApprove,
+ },
+ },
+ },
+ },
+ }),
+ ],
+ ];
+ const apolloProvider = createMockApollo(requestHandlers);
const store = new Vuex.Store({
getters: {
@@ -27,12 +52,17 @@ function factory({ canApprove = true, shouldAnimateReviewButton = false } = {})
getNoteableData: () => ({
id: 1,
preview_note_path: '/preview',
- current_user: { can_approve: canApprove },
}),
noteableType: () => 'merge_request',
getCurrentUserLastNote: () => ({ id: 1 }),
},
modules: {
+ diffs: {
+ namespaced: true,
+ state: {
+ projectPath: 'gitlab-org/gitlab',
+ },
+ },
batchComments: {
namespaced: true,
state: { shouldAnimateReviewButton },
@@ -44,6 +74,7 @@ function factory({ canApprove = true, shouldAnimateReviewButton = false } = {})
});
wrapper = mountExtended(SubmitDropdown, {
store,
+ apolloProvider,
});
}
@@ -113,11 +144,18 @@ describe('Batch comments submit dropdown', () => {
canApprove | exists | existsText
${true} | ${true} | ${'shows'}
${false} | ${false} | ${'hides'}
- `('$existsText approve checkbox if can_approve is $canApprove', ({ canApprove, exists }) => {
- factory({ canApprove });
+ `(
+ '$existsText approve checkbox if can_approve is $canApprove',
+ async ({ canApprove, exists }) => {
+ factory({ canApprove });
- expect(wrapper.findByTestId('approve_merge_request').exists()).toBe(exists);
- });
+ wrapper.findComponent(GlDisclosureDropdown).vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(wrapper.findByTestId('approve_merge_request').exists()).toBe(exists);
+ },
+ );
it.each`
shouldAnimateReviewButton | animationClassApplied | classText
diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js
index a98e79c69fe..c3f22749978 100644
--- a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js
+++ b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js
@@ -19,6 +19,10 @@ describe('graph component', () => {
const findLinksLayer = () => wrapper.findComponent(LinksLayer);
const findStageColumns = () => wrapper.findAllComponents(StageColumnComponent);
const findStageNameInJob = () => wrapper.findByTestId('stage-name-in-job');
+ const findPipelineContainer = () => wrapper.findByTestId('pipeline-container');
+ const findRootGraphLayout = () => wrapper.findByTestId('stage-column');
+ const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title');
+ const findJobItem = () => wrapper.findComponent(JobItem);
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
@@ -42,6 +46,9 @@ describe('graph component', () => {
mountFn = shallowMount,
props = {},
stubOverride = {},
+ glFeatures = {
+ newPipelineGraph: false,
+ },
} = {}) => {
wrapper = mountFn(PipelineGraph, {
propsData: {
@@ -61,6 +68,9 @@ describe('graph component', () => {
'job-group-dropdown': true,
...stubOverride,
},
+ provide: {
+ glFeatures,
+ },
});
};
@@ -112,9 +122,8 @@ describe('graph component', () => {
});
it('dims unrelated jobs', () => {
- const unrelatedJob = wrapper.findComponent(JobItem);
expect(findLinksLayer().emitted().highlightedJobsChange).toHaveLength(1);
- expect(unrelatedJob.classes('gl-opacity-3')).toBe(true);
+ expect(findJobItem().classes('gl-opacity-3')).toBe(true);
});
});
});
@@ -179,4 +188,82 @@ describe('graph component', () => {
expect(findDownstreamColumn().props().linkedPipelines).toHaveLength(1);
});
});
+
+ describe.each`
+ name | value | state
+ ${'disabled'} | ${false} | ${'should not'}
+ ${'enabled'} | ${true} | ${'should'}
+ `('With feature flag newPipelineGraph $name', ({ value, state }) => {
+ beforeEach(() => {
+ createComponent({
+ mountFn: mountExtended,
+ stubOverride: { 'job-item': false, StageColumnComponent },
+ glFeatures: {
+ newPipelineGraph: value,
+ },
+ stubs: {
+ StageColumnComponent,
+ },
+ });
+ });
+
+ it(`${state} add class pipeline-graph-container on wrapper`, () => {
+ expect(findPipelineContainer().classes('pipeline-graph-container')).toBe(value);
+ });
+
+ it(`${state} add class is-stage-view on rootGraphLayout`, () => {
+ expect(findRootGraphLayout().classes('is-stage-view')).toBe(value);
+ });
+
+ it(`${state} add titleClasses on stageColumnTitle`, () => {
+ const titleClasses = [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-4',
+ 'gl-mb-n2',
+ ];
+ const legacyTitleClasses = [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-3',
+ ];
+ const checkClasses = value ? titleClasses : legacyTitleClasses;
+
+ expect(findStageColumnTitle().classes()).toEqual(expect.arrayContaining(checkClasses));
+ });
+
+ it(`${state} add jobClasses on findJobItem`, () => {
+ const jobClasses = [
+ 'gl-p-3',
+ 'gl-border-0',
+ 'gl-bg-transparent',
+ 'gl-rounded-base',
+ 'gl-hover-bg-gray-50',
+ 'gl-focus-bg-gray-50',
+ 'gl-hover-text-gray-900',
+ 'gl-focus-text-gray-900',
+ ];
+ const legacyJobClasses = [
+ 'gl-p-3',
+ 'gl-border-gray-100',
+ 'gl-border-solid',
+ 'gl-border-1',
+ 'gl-bg-white',
+ 'gl-rounded-7',
+ 'gl-hover-bg-gray-50',
+ 'gl-focus-bg-gray-50',
+ 'gl-hover-text-gray-900',
+ 'gl-focus-text-gray-900',
+ 'gl-hover-border-gray-200',
+ 'gl-focus-border-gray-200',
+ ];
+ const checkClasses = value ? jobClasses : legacyJobClasses;
+
+ expect(findJobItem().props('cssClassJobName')).toEqual(expect.arrayContaining(checkClasses));
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 47fd96ff625..5299361a493 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -22,11 +22,14 @@ import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vu
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
import eventHub from '~/diffs/event_hub';
+import { EVT_DISCUSSIONS_ASSIGNED } from '~/diffs/constants';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { Mousetrap } from '~/lib/mousetrap';
import * as urlUtils from '~/lib/utils/url_utility';
+import * as commonUtils from '~/lib/utils/common_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { stubPerformanceWebAPI } from 'helpers/performance';
import createDiffsStore from '../create_diffs_store';
import diffsMockData from '../mock_data/merge_request_diffs';
@@ -662,6 +665,12 @@ describe('diffs/components/app', () => {
});
describe('file-by-file', () => {
+ let hashSpy;
+
+ beforeEach(() => {
+ hashSpy = jest.spyOn(commonUtils, 'handleLocationHash');
+ });
+
it('renders a single diff', async () => {
createComponent(
undefined,
@@ -681,6 +690,48 @@ describe('diffs/components/app', () => {
expect(wrapper.findAllComponents(DiffFile).length).toBe(1);
});
+ describe('rechecking the url hash for scrolling', () => {
+ const advanceAndCheckCalls = (count = 0) => {
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ expect(hashSpy).toHaveBeenCalledTimes(count);
+ };
+
+ it('re-checks one time after the file finishes loading', () => {
+ createComponent(
+ undefined,
+ ({ state }) => {
+ state.diffs.diffFiles = [{ isLoadingFullFile: true }];
+ },
+ undefined,
+ { viewDiffsFileByFile: true },
+ );
+
+ // The hash check is not called if the file is still marked as loading
+ expect(hashSpy).toHaveBeenCalledTimes(0);
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+ advanceAndCheckCalls();
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+ advanceAndCheckCalls();
+ // Once the file has finished loading, it calls through to check the hash
+ store.state.diffs.diffFiles[0].isLoadingFullFile = false;
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+ advanceAndCheckCalls(1);
+ // No further scrolls happen after one hash check / scroll
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+ advanceAndCheckCalls(1);
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+ advanceAndCheckCalls(1);
+ });
+
+ it('does not re-check when not in single-file mode', () => {
+ createComponent();
+
+ eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED);
+
+ expect(hashSpy).not.toHaveBeenCalled();
+ });
+ });
+
describe('pagination', () => {
const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]');
const paginator = () => fileByFileNav().findComponent(GlPagination);
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 13efd3584b4..34af3d72b04 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -13,6 +13,7 @@ import DiffFileComponent from '~/diffs/components/diff_file.vue';
import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue';
import {
+ EVT_DISCUSSIONS_ASSIGNED,
EVT_EXPAND_ALL_FILES,
EVT_PERF_MARK_DIFF_FILES_END,
EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
@@ -271,9 +272,10 @@ describe('DiffFile', () => {
await nextTick(); // Wait for the idleCallback
await nextTick(); // Wait for nextTick inside postRender
- expect(eventHub.$emit).toHaveBeenCalledTimes(2);
+ expect(eventHub.$emit).toHaveBeenCalledTimes(3);
expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_DISCUSSIONS_ASSIGNED);
});
});
});
diff --git a/spec/frontend/search/sidebar/components/blobs_filters_spec.js b/spec/frontend/search/sidebar/components/blobs_filters_spec.js
index 729fae44c19..245ddb8f8bb 100644
--- a/spec/frontend/search/sidebar/components/blobs_filters_spec.js
+++ b/spec/frontend/search/sidebar/components/blobs_filters_spec.js
@@ -17,7 +17,7 @@ describe('GlobalSearch BlobsFilters', () => {
currentScope: () => 'blobs',
};
- const createComponent = ({ initialState = {}, searchBlobsHideArchivedProjects = true } = {}) => {
+ const createComponent = ({ initialState = {} } = {}) => {
const store = new Vuex.Store({
state: {
urlQuery: MOCK_QUERY,
@@ -30,11 +30,6 @@ describe('GlobalSearch BlobsFilters', () => {
wrapper = shallowMount(BlobsFilters, {
store,
- provide: {
- glFeatures: {
- searchBlobsHideArchivedProjects,
- },
- },
});
};
@@ -42,29 +37,20 @@ describe('GlobalSearch BlobsFilters', () => {
const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter);
const findDividers = () => wrapper.findAll('hr');
- describe.each`
- description | searchBlobsHideArchivedProjects
- ${'Renders correctly with Archived Filter enabled'} | ${true}
- ${'Renders correctly with Archived Filter disabled'} | ${false}
- `('$description', ({ searchBlobsHideArchivedProjects }) => {
- beforeEach(() => {
- createComponent({
- searchBlobsHideArchivedProjects,
- });
- });
+ beforeEach(() => {
+ createComponent({});
+ });
- it('renders LanguageFilter', () => {
- expect(findLanguageFilter().exists()).toBe(true);
- });
+ it('renders LanguageFilter', () => {
+ expect(findLanguageFilter().exists()).toBe(true);
+ });
- it(`renders correctly ArchivedFilter when searchBlobsHideArchivedProjects is ${searchBlobsHideArchivedProjects}`, () => {
- expect(findArchivedFilter().exists()).toBe(searchBlobsHideArchivedProjects);
- });
+ it('renders ArchivedFilter', () => {
+ expect(findArchivedFilter().exists()).toBe(true);
+ });
- it('renders divider correctly', () => {
- const dividersCount = searchBlobsHideArchivedProjects ? 1 : 0;
- expect(findDividers()).toHaveLength(dividersCount);
- });
+ it('renders divider correctly', () => {
+ expect(findDividers()).toHaveLength(1);
});
describe('Renders correctly in new nav', () => {
@@ -74,7 +60,6 @@ describe('GlobalSearch BlobsFilters', () => {
searchType: SEARCH_TYPE_ADVANCED,
useSidebarNavigation: true,
},
- searchBlobsHideArchivedProjects: true,
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js
new file mode 100644
index 00000000000..3ec791d421f
--- /dev/null
+++ b/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js
@@ -0,0 +1,39 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import notesEventHub from '~/notes/event_hub';
+import MergeChecksUnresolvedDiscussions from '~/vue_merge_request_widget/components/checks/unresolved_discussions.vue';
+import MergeChecksMessage from '~/vue_merge_request_widget/components/checks/message.vue';
+
+describe('MergeChecksUnresolvedDiscussions component', () => {
+ let wrapper;
+
+ function createComponent(
+ propsData = { check: { result: 'failed', failureReason: 'Failed message' } },
+ ) {
+ wrapper = mountExtended(MergeChecksUnresolvedDiscussions, {
+ propsData,
+ });
+ }
+
+ it('passes check down to the MergeChecksMessage', () => {
+ const check = { result: 'failed', failureReason: 'Unresolved discussions' };
+ createComponent({ check });
+
+ expect(wrapper.findComponent(MergeChecksMessage).props('check')).toEqual(check);
+ });
+
+ it('does not show go to first unresolved discussion button with passed state', () => {
+ createComponent({ check: { result: 'passed' } });
+ const button = wrapper.findByRole('button', { name: 'Go to first unresolved thread' });
+ expect(button.exists()).toBe(false);
+ });
+
+ it('triggers go to first discussion action', () => {
+ const callback = jest.fn();
+ notesEventHub.$on('jumpToFirstUnresolvedDiscussion', callback);
+ createComponent();
+
+ wrapper.findByRole('button', { name: 'Go to first unresolved thread' }).trigger('click');
+
+ expect(callback).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
index 6224d6e42ee..f8a09391e5d 100644
--- a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
@@ -1,18 +1,22 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
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 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';
+import rebaseStateQuery from '~/vue_merge_request_widget/queries/states/rebase.query.graphql';
Vue.use(VueApollo);
let wrapper;
let apolloProvider;
-function factory({ canMerge = true, mergeChecks = [] } = {}) {
+function factory(mountFn, { canMerge = true, mergeChecks = [] } = {}) {
apolloProvider = createMockApollo([
[
mergeChecksQuery,
@@ -25,9 +29,56 @@ function factory({ canMerge = true, mergeChecks = [] } = {}) {
},
}),
],
+ [
+ conflictsStateQuery,
+ () =>
+ Promise.resolve({
+ data: {
+ project: {
+ id: 1,
+ mergeRequest: {
+ id: 1,
+ shouldBeRebased: false,
+ sourceBranchProtected: false,
+ userPermissions: { pushToSourceBranch: true },
+ },
+ },
+ },
+ }),
+ ],
+ [
+ rebaseStateQuery,
+ () =>
+ Promise.resolve({
+ data: {
+ project: {
+ id: '1',
+ mergeRequest: {
+ id: '2',
+ rebaseInProgress: false,
+ targetBranch: 'main',
+ userPermissions: {
+ pushToSourceBranch: true,
+ },
+ pipelines: {
+ nodes: [
+ {
+ id: '1',
+ project: {
+ id: '2',
+ fullPath: 'gitlab/gitlab',
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ }),
+ ],
]);
- wrapper = mountExtended(MergeChecksComponent, {
+ wrapper = mountFn(MergeChecksComponent, {
apolloProvider,
propsData: {
mr: {},
@@ -36,13 +87,16 @@ function factory({ canMerge = true, mergeChecks = [] } = {}) {
});
}
+const mountComponent = factory.bind(null, mountExtended);
+const shallowMountComponent = factory.bind(null, shallowMountExtended);
+
describe('Merge request merge checks component', () => {
afterEach(() => {
apolloProvider = null;
});
it('renders ready to merge text if user can merge', async () => {
- factory({ canMerge: true });
+ mountComponent({ canMerge: true });
await waitForPromises();
@@ -50,7 +104,7 @@ describe('Merge request merge checks component', () => {
});
it('renders ready to merge by members text if user can not merge', async () => {
- factory({ canMerge: false });
+ mountComponent({ canMerge: false });
await waitForPromises();
@@ -62,7 +116,7 @@ describe('Merge request merge checks component', () => {
${[{ identifier: 'discussions', result: 'failed' }]} | ${'Merge blocked: 1 check failed'}
${[{ identifier: 'discussions', result: 'failed' }, { identifier: 'rebase', result: 'failed' }]} | ${'Merge blocked: 2 checks failed'}
`('renders $text for $mergeChecks', async ({ mergeChecks, text }) => {
- factory({ mergeChecks });
+ mountComponent({ mergeChecks });
await waitForPromises();
@@ -74,15 +128,33 @@ describe('Merge request merge checks component', () => {
${'failed'} | ${'failed'}
${'passed'} | ${'success'}
`('renders $statusIcon for $result result', async ({ result, statusIcon }) => {
- factory({ mergeChecks: [{ result, identifier: 'discussions' }] });
+ mountComponent({ mergeChecks: [{ result, identifier: 'discussions' }] });
await waitForPromises();
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe(statusIcon);
});
+ it.each`
+ identifier
+ ${'conflicts'}
+ ${'unresolved_discussions'}
+ ${'rebase'}
+ ${'default'}
+ `('renders $identifier merge check', async ({ identifier }) => {
+ shallowMountComponent({ mergeChecks: [{ result: 'failed', identifier }] });
+
+ wrapper.findComponent(StateContainer).vm.$emit('toggle');
+
+ await waitForPromises();
+
+ const { default: component } = await COMPONENTS[identifier]();
+
+ expect(wrapper.findComponent(component).exists()).toBe(true);
+ });
+
it('expands collapsed area', async () => {
- factory();
+ mountComponent();
await waitForPromises();
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
index 16751bcc0f0..213959fe4e2 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js
@@ -2,6 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
import DynamicContent from '~/vue_merge_request_widget/components/widget/dynamic_content.vue';
import ContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () => {
let wrapper;
@@ -16,10 +17,13 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', ()
DynamicContent,
ContentRow,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
- it('renders given data', () => {
+ beforeEach(() => {
createComponent({
propsData: {
data: {
@@ -49,10 +53,23 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', ()
text: 'This is recursive. It will be listed in level 3.',
},
],
+ tooltipText: 'Tooltip text',
},
},
});
+ });
+ it('renders given data', () => {
expect(wrapper.html()).toMatchSnapshot();
});
+
+ it('has a tooltip on the row text', () => {
+ const text = wrapper.findByText('Main text for the row');
+ const tooltip = getBinding(text.element, 'gl-tooltip');
+
+ expect(tooltip.value).toMatchObject({
+ title: 'Tooltip text',
+ boundary: 'viewport',
+ });
+ });
});
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index 8f37bf29a4b..4af7fae400e 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -323,34 +323,4 @@ RSpec.describe VisibilityLevelHelper, feature_category: :system_access do
it { is_expected.to eq(expected) }
end
end
-
- describe '#visibility_level_options' do
- let(:user) { build(:user) }
-
- before do
- allow(helper).to receive(:current_user).and_return(user)
- end
-
- it 'returns the desired mapping' do
- expected_options = [
- {
- level: 0,
- label: 'Private',
- description: 'The group and its projects can only be viewed by members.'
- },
- {
- level: 10,
- label: 'Internal',
- description: 'The group and any internal projects can be viewed by any logged in user except external users.'
- },
- {
- level: 20,
- label: 'Public',
- description: 'The group and any public projects can be viewed without any authentication.'
- }
- ]
-
- expect(helper.visibility_level_options(group)).to eq expected_options
- end
- end
end
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index d7b69546de6..8396d5469ad 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -157,6 +157,18 @@ RSpec.describe Integration, feature_category: :integrations do
include_examples 'hook scope', 'incident'
end
+ describe '.title' do
+ it 'raises an error' do
+ expect { described_class.title }.to raise_error(NotImplementedError)
+ end
+ end
+
+ describe '.description' do
+ it 'raises an error' do
+ expect { described_class.description }.to raise_error(NotImplementedError)
+ end
+ end
+
describe '#operating?' do
it 'is false when the integration is not active' do
expect(build(:integration).operating?).to eq(false)
diff --git a/spec/models/integrations/base_chat_notification_spec.rb b/spec/models/integrations/base_chat_notification_spec.rb
index 497f2f1e7c9..9ad37f40fbc 100644
--- a/spec/models/integrations/base_chat_notification_spec.rb
+++ b/spec/models/integrations/base_chat_notification_spec.rb
@@ -347,12 +347,6 @@ RSpec.describe Integrations::BaseChatNotification, feature_category: :integratio
end
end
- describe '#help' do
- it 'raises an error' do
- expect { subject.help }.to raise_error(NotImplementedError)
- end
- end
-
describe '#event_channel_name' do
it 'returns the channel field name for the given event' do
expect(subject.event_channel_name(:event)).to eq('event_channel')
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 64e010aa50f..ba3103fd52f 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -3688,6 +3688,16 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and
it_behaves_like '412 response' do
subject(:request) { api("/projects/#{project.id}/share/#{group.id}", user) }
end
+
+ it "returns an error when link is not destroyed" do
+ allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute)
+ .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found))
+
+ delete api("/projects/#{project.id}/share/#{group.id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq '404 Not Found'
+ end
end
it 'returns a 400 when group id is not an integer' do
diff --git a/spec/services/projects/group_links/create_service_spec.rb b/spec/services/projects/group_links/create_service_spec.rb
index ca2902af472..e3f170ef3fe 100644
--- a/spec/services/projects/group_links/create_service_spec.rb
+++ b/spec/services/projects/group_links/create_service_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute', feature_category
let_it_be(:user) { create :user }
let_it_be(:group) { create :group }
let_it_be(:project) { create(:project, namespace: create(:namespace, :with_namespace_settings)) }
+ let_it_be(:group_user) { create(:user).tap { |user| group.add_guest(user) } }
let(:opts) do
{
@@ -37,67 +38,75 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute', feature_category
end
end
- context 'when user has proper membership to share a group' do
+ context 'when user has proper permissions to share a project with a group' do
before do
group.add_guest(user)
end
- it_behaves_like 'shareable'
-
- it 'updates authorization', :sidekiq_inline do
- expect { subject.execute }.to(
- change { Ability.allowed?(user, :read_project, project) }
- .from(false).to(true))
- end
-
- context 'with specialized project_authorization workers' do
- let_it_be(:other_user) { create(:user) }
-
+ context 'when the user is a MAINTAINER in the project' do
before do
- group.add_developer(other_user)
+ project.add_maintainer(user)
end
- it 'schedules authorization update for users with access to group' do
- stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
-
- expect(AuthorizedProjectsWorker).not_to(
- receive(:bulk_perform_async)
- )
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to(
- receive(:perform_async)
- .with(project.id)
- .and_call_original
- )
- expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in).with(
- 1.hour,
- array_including([user.id], [other_user.id]),
- batch_delay: 30.seconds, batch_size: 100
- ).and_call_original
- )
-
- subject.execute
+ it_behaves_like 'shareable'
+
+ it 'updates authorization', :sidekiq_inline do
+ expect { subject.execute }.to(
+ change { Ability.allowed?(group_user, :read_project, project) }
+ .from(false).to(true))
end
- end
- context 'when sharing outside the hierarchy is disabled' do
- let_it_be(:shared_group_parent) do
- create(:group, namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true))
+ context 'with specialized project_authorization workers' do
+ let_it_be(:other_user) { create(:user) }
+
+ before do
+ group.add_developer(other_user)
+ end
+
+ it 'schedules authorization update for users with access to group' do
+ stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
+
+ expect(AuthorizedProjectsWorker).not_to(
+ receive(:bulk_perform_async)
+ )
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to(
+ receive(:perform_async)
+ .with(project.id)
+ .and_call_original
+ )
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ array_including([user.id], [other_user.id]),
+ batch_delay: 30.seconds, batch_size: 100
+ ).and_call_original
+ )
+
+ subject.execute
+ end
end
- let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) }
+ context 'when sharing outside the hierarchy is disabled' do
+ let_it_be(:shared_group_parent) do
+ create(:group,
+ namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true)
+ )
+ end
+
+ let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) }
- it_behaves_like 'not shareable'
+ it_behaves_like 'not shareable'
- context 'when group is inside hierarchy' do
- let(:group) { create(:group, :private, parent: shared_group_parent) }
+ context 'when group is inside hierarchy' do
+ let(:group) { create(:group, :private, parent: shared_group_parent) }
- it_behaves_like 'shareable'
+ it_behaves_like 'shareable'
+ end
end
end
end
- context 'when user does not have permissions for the group' do
+ context 'when user does not have permissions to share the project with a group' do
it_behaves_like 'not shareable'
end
diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb
index 103aff8c659..0cd003f6142 100644
--- a/spec/services/projects/group_links/destroy_service_spec.rb
+++ b/spec/services/projects/group_links/destroy_service_spec.rb
@@ -6,83 +6,120 @@ RSpec.describe Projects::GroupLinks::DestroyService, '#execute', feature_categor
let_it_be(:user) { create :user }
let_it_be(:project) { create(:project, :private) }
let_it_be(:group) { create(:group) }
+ let_it_be(:group_user) { create(:user).tap { |user| group.add_guest(user) } }
- let!(:group_link) { create(:project_group_link, project: project, group: group) }
+ let(:group_access) { Gitlab::Access::DEVELOPER }
+ let!(:group_link) { create(:project_group_link, project: project, group: group, group_access: group_access) }
subject { described_class.new(project, user) }
- it 'removes group from project' do
- expect { subject.execute(group_link) }.to change { project.project_group_links.count }.from(1).to(0)
- end
-
- context 'project authorizations refresh' do
- before do
- group.add_maintainer(user)
+ shared_examples_for 'removes group from project' do
+ it 'removes group from project' do
+ expect { subject.execute(group_link) }.to change { project.reload.project_group_links.count }.from(1).to(0)
end
+ end
- it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
- .to receive(:perform_async).with(group_link.project.id)
+ shared_examples_for 'returns not_found' do
+ it do
+ expect do
+ result = subject.execute(group_link)
- subject.execute(group_link)
+ expect(result[:status]).to eq(:error)
+ expect(result[:reason]).to eq(:not_found)
+ end.not_to change { project.reload.project_group_links.count }
end
+ end
- it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do
- stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
-
- expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in).with(
- 1.hour,
- [[user.id]],
- batch_delay: 30.seconds, batch_size: 100
- )
- )
-
- subject.execute(group_link)
- end
+ context 'if group_link is blank' do
+ let!(:group_link) { nil }
- it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do
- expect { subject.execute(group_link) }.to(
- change { Ability.allowed?(user, :read_project, project) }
- .from(true).to(false))
- end
+ it_behaves_like 'returns not_found'
end
- it 'returns false if group_link is blank' do
- expect { subject.execute(nil) }.not_to change { project.project_group_links.count }
+ context 'if the user does not have access to destroy the link' do
+ it_behaves_like 'returns not_found'
end
- describe 'todos cleanup' do
- context 'when project is private' do
- it 'triggers todos cleanup' do
- expect(TodosDestroyer::ProjectPrivateWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id)
- expect(project.private?).to be true
-
- subject.execute(group_link)
+ context 'when the user has proper permissions to remove a group-link from a project' do
+ context 'when the user is a MAINTAINER in the project' do
+ before do
+ project.add_maintainer(user)
end
- end
- context 'when project is public or internal' do
- shared_examples_for 'removes confidential todos' do
- it 'does not trigger todos cleanup' do
- expect(TodosDestroyer::ProjectPrivateWorker).not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id)
- expect(TodosDestroyer::ConfidentialIssueWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, nil, project.id)
- expect(project.private?).to be false
+ it_behaves_like 'removes group from project'
+
+ context 'project authorizations refresh' do
+ it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
+ .to receive(:perform_async).with(group_link.project.id)
subject.execute(group_link)
end
- end
- context 'when project is public' do
- let(:project) { create(:project, :public) }
+ it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do
+ stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
- it_behaves_like 'removes confidential todos'
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ [[group_user.id]],
+ batch_delay: 30.seconds, batch_size: 100
+ )
+ )
+
+ subject.execute(group_link)
+ end
+
+ it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do
+ expect { subject.execute(group_link) }.to(
+ change { Ability.allowed?(group_user, :read_project, project) }
+ .from(true).to(false))
+ end
end
- context 'when project is internal' do
- let(:project) { create(:project, :public) }
+ describe 'todos cleanup' do
+ context 'when project is private' do
+ it 'triggers todos cleanup' do
+ expect(TodosDestroyer::ProjectPrivateWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id)
+ expect(project.private?).to be true
+
+ subject.execute(group_link)
+ end
+ end
+
+ context 'when project is public or internal' do
+ shared_examples_for 'removes confidential todos' do
+ it 'does not trigger todos cleanup' do
+ expect(TodosDestroyer::ProjectPrivateWorker).not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id)
+ expect(TodosDestroyer::ConfidentialIssueWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, nil, project.id)
+ expect(project.private?).to be false
+
+ subject.execute(group_link)
+ end
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it_behaves_like 'removes confidential todos'
+ end
+
+ context 'when project is internal' do
+ let(:project) { create(:project, :public) }
+
+ it_behaves_like 'removes confidential todos'
+ end
+ end
+ end
+ end
+ end
- it_behaves_like 'removes confidential todos'
+ context 'when skipping authorization' do
+ context 'without providing a user' do
+ it 'destroys the link' do
+ expect do
+ described_class.new(project, nil).execute(group_link, skip_authorization: true)
+ end.to change { project.reload.project_group_links.count }.by(-1)
end
end
end
diff --git a/spec/services/projects/group_links/update_service_spec.rb b/spec/services/projects/group_links/update_service_spec.rb
index f7607deef04..b02614fa062 100644
--- a/spec/services/projects/group_links/update_service_spec.rb
+++ b/spec/services/projects/group_links/update_service_spec.rb
@@ -6,8 +6,11 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute', feature_category
let_it_be(:user) { create :user }
let_it_be(:group) { create :group }
let_it_be(:project) { create :project }
+ let_it_be(:group_user) { create(:user).tap { |user| group.add_developer(user) } }
- let!(:link) { create(:project_group_link, project: project, group: group) }
+ let(:group_access) { Gitlab::Access::DEVELOPER }
+
+ let!(:link) { create(:project_group_link, project: project, group: group, group_access: group_access) }
let(:expiry_date) { 1.month.from_now.to_date }
let(:group_link_params) do
@@ -17,60 +20,78 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute', feature_category
subject { described_class.new(link, user).execute(group_link_params) }
- before do
- group.add_developer(user)
- end
-
- it 'updates existing link' do
- expect(link.group_access).to eq(Gitlab::Access::DEVELOPER)
- expect(link.expires_at).to be_nil
-
- subject
-
- link.reload
+ shared_examples_for 'returns not_found' do
+ it do
+ result = subject
- expect(link.group_access).to eq(Gitlab::Access::GUEST)
- expect(link.expires_at).to eq(expiry_date)
- end
-
- context 'project authorizations update' do
- it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
- .to receive(:perform_async).with(link.project.id)
-
- subject
- end
-
- it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do
- stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
-
- expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in).with(
- 1.hour,
- [[user.id]],
- batch_delay: 30.seconds, batch_size: 100
- )
- )
-
- subject
- end
-
- it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do
- group.add_maintainer(user)
-
- expect { subject }.to(
- change { Ability.allowed?(user, :create_release, project) }
- .from(true).to(false))
+ expect(result[:status]).to eq(:error)
+ expect(result[:reason]).to eq(:not_found)
end
end
- context 'with only param not requiring authorization refresh' do
- let(:group_link_params) { { expires_at: Date.tomorrow } }
-
- it 'does not perform any project authorizations update using `AuthorizedProjectUpdate::ProjectRecalculateWorker`' do
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async)
+ context 'when the user does not have proper permissions to update a project group link' do
+ it_behaves_like 'returns not_found'
+ end
- subject
+ context 'when user has proper permissions to update a project group link' do
+ context 'when the user is a MAINTAINER in the project' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates existing link' do
+ expect(link.group_access).to eq(Gitlab::Access::DEVELOPER)
+ expect(link.expires_at).to be_nil
+
+ subject
+
+ link.reload
+
+ expect(link.group_access).to eq(Gitlab::Access::GUEST)
+ expect(link.expires_at).to eq(expiry_date)
+ end
+
+ context 'project authorizations update' do
+ it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
+ .to receive(:perform_async).with(link.project.id)
+
+ subject
+ end
+
+ it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker ' \
+ 'with a delay to update project authorizations' do
+ stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false)
+
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
+ receive(:bulk_perform_in).with(
+ 1.hour,
+ [[group_user.id]],
+ batch_delay: 30.seconds, batch_size: 100
+ )
+ )
+
+ subject
+ end
+
+ it 'updates project authorizations of users who had access to the project via the group share',
+ :sidekiq_inline do
+ expect { subject }.to(
+ change { Ability.allowed?(group_user, :developer_access, project) }
+ .from(true).to(false))
+ end
+ end
+
+ context 'with only param not requiring authorization refresh' do
+ let(:group_link_params) { { expires_at: Date.tomorrow } }
+
+ it 'does not perform any project authorizations update using ' \
+ '`AuthorizedProjectUpdate::ProjectRecalculateWorker`' do
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
end
end
end
diff --git a/vendor/project_templates/bridgetown.tar.gz b/vendor/project_templates/bridgetown.tar.gz
index 1fb89694d0f..8b4c63e1be5 100644
--- a/vendor/project_templates/bridgetown.tar.gz
+++ b/vendor/project_templates/bridgetown.tar.gz
Binary files differ
diff --git a/vendor/project_templates/middleman.tar.gz b/vendor/project_templates/middleman.tar.gz
index db09a84ab75..b926aa92a20 100644
--- a/vendor/project_templates/middleman.tar.gz
+++ b/vendor/project_templates/middleman.tar.gz
Binary files differ