Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-07-15 06:09:39 +00:00
parent a3ac132686
commit 01ef10900a
58 changed files with 715 additions and 543 deletions

View File

@ -189,7 +189,7 @@
- "config.ru"
# List explicitly all the app/ dirs that are backend (i.e. all except app/assets).
- "{,ee/,jh/}{app/channels,app/controllers,app/finders,app/graphql,app/helpers,app/mailers,app/models,app/policies,app/presenters,app/serializers,app/services,app/uploaders,app/validators,app/views,app/workers}/**/*"
- "{,ee/,jh/}{bin,cable,config,db,lib}/**/*"
- "{,ee/,jh/}{bin,cable,config,db,generator_templates,lib}/**/*"
- "{,ee/,jh/}spec/**/*.rb"
# CI changes
- ".gitlab-ci.yml"
@ -239,7 +239,7 @@
- "Rakefile"
- "tests.yml"
- "config.ru"
- "{,ee/,jh/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "{,ee/,jh/}{app,bin,config,db,generator_templates,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
@ -264,7 +264,7 @@
- "Rakefile"
- "tests.yml"
- "config.ru"
- "{,ee/,jh/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "{,ee/,jh/}{app,bin,config,db,generator_templates,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# Backstage changes
@ -292,7 +292,7 @@
- "Rakefile"
- "tests.yml"
- "config.ru"
- "{,ee/,jh/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "{,ee/,jh/}{app,bin,config,db,generator_templates,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# QA changes
@ -316,7 +316,7 @@
- "Rakefile"
- "tests.yml"
- "config.ru"
- "{,ee/,jh/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "{,ee/,jh/}{app,bin,config,db,generator_templates,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# Backstage changes

View File

@ -1 +1 @@
9cde7b7d1ecc68f5ca3b68df5ad32e6b0bc9d661
ebabe4399c781ea1f4a1a43774b14489446f1f68

View File

@ -11,6 +11,7 @@ import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assig
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@ -24,6 +25,7 @@ export default {
BoardSidebarLabelsSelect,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
SidebarTodoWidget,
MountingPortal,
SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
@ -90,6 +92,15 @@ export default {
<template #title>
<h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2>
</template>
<template #header>
<sidebar-todo-widget
class="gl-mt-3"
:issuable-id="activeBoardItem.fullId"
:issuable-iid="activeBoardItem.iid"
:full-path="fullPath"
:issuable-type="issuableType"
/>
</template>
<template #default>
<board-sidebar-title />
<sidebar-assignees-widget

View File

@ -1,6 +1,6 @@
<script>
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
import TodoButton from '~/vue_shared/components/sidebar/todo_button.vue';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql';
import getDesignQuery from '../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../mixins/all_versions';

View File

@ -2,7 +2,7 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
import { fixTitle, hide } from '~/tooltips';
import { hide } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
@ -107,36 +107,6 @@ Sidebar.prototype.toggleTodo = function (e) {
);
};
Sidebar.prototype.todoUpdateDone = function (data) {
const deletePath = data.delete_path ? data.delete_path : null;
const attrPrefix = deletePath ? 'mark' : 'todo';
const $todoBtns = $('.js-issuable-todo');
$(document).trigger('todo:toggle', data.count);
$todoBtns.each((i, el) => {
const $el = $(el);
const $elText = $el.find('.js-issuable-todo-inner');
$el
.removeClass('is-loading')
.enable()
.attr('aria-label', $el.data(`${attrPrefix}Text`))
.attr('title', $el.data(`${attrPrefix}Text`))
.data('deletePath', deletePath);
if ($el.hasClass('has-tooltip')) {
fixTitle(el);
}
if (typeof $el.data('isCollapsed') !== 'undefined') {
$elText.html($el.data(`${attrPrefix}Icon`));
} else {
$elText.text($el.data(`${attrPrefix}Text`));
}
});
};
Sidebar.prototype.sidebarCollapseClicked = function (e) {
if ($(e.currentTarget).hasClass('dont-change-state')) {
return;

View File

@ -1,18 +1,26 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
import TodoButton from '~/vue_shared/components/sidebar/todo_button.vue';
import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
export default {
components: {
GlButton,
GlIcon,
TodoButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
isClassicSidebar: {
default: false,
},
},
props: {
issuableId: {
type: String,
@ -86,6 +94,12 @@ export default {
}
return TodoMutationTypes.Create;
},
collapsedButtonIcon() {
return this.hasTodo ? 'todo-done' : 'todo-add';
},
tootltipTitle() {
return todoLabel(this.hasTodo);
},
},
methods: {
toggleTodo() {
@ -158,7 +172,24 @@ export default {
:is-todo="hasTodo"
:loading="isLoading"
size="small"
class="hide-collapsed"
@click.stop.prevent="toggleTodo"
/>
<gl-button
v-if="isClassicSidebar"
category="tertiary"
type="reset"
class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!"
@click.stop.prevent="toggleTodo"
>
<gl-icon
v-gl-tooltip.left.viewport
:title="tootltipTitle"
:size="16"
:class="{ 'todo-undone': hasTodo }"
:name="collapsedButtonIcon"
:aria-label="collapsedButtonIcon"
/>
</gl-button>
</div>
</template>

View File

@ -13,10 +13,12 @@ import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql';
import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql';
import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql';
import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql';
import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql';
import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
@ -216,6 +218,12 @@ export const todoQueries = {
[IssuableType.Epic]: {
query: epicTodoQuery,
},
[IssuableType.Issue]: {
query: issueTodoQuery,
},
[IssuableType.MergeRequest]: {
query: mergeRequestTodoQuery,
},
};
export const TodoMutationTypes = {

View File

@ -2,6 +2,8 @@ import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issue_show/constants';
@ -19,6 +21,7 @@ import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import Translate from '../vue_shared/translate';
@ -40,6 +43,40 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
function mountSidebarToDoWidget() {
const el = document.querySelector('.js-issuable-todo');
if (!el) {
return false;
}
const { projectPath, iid, id } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
SidebarTodoWidget,
},
provide: {
isClassicSidebar: true,
},
render: (createElement) =>
createElement('sidebar-todo-widget', {
props: {
fullPath: projectPath,
issuableId:
isInIssuePage() || isInDesignPage()
? convertToGraphQLId(TYPE_ISSUE, id)
: convertToGraphQLId(TYPE_MERGE_REQUEST, id),
issuableIid: iid,
issuableType:
isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
},
}),
});
}
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
@ -497,6 +534,7 @@ export function mountSidebar(mediator) {
initInviteMembersModal();
initInviteMembersTrigger();
mountSidebarToDoWidget();
if (isAssigneesWidgetShown) {
mountAssigneesComponent();
} else {

View File

@ -0,0 +1,14 @@
query issueTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
currentUserTodos(state: pending) {
nodes {
id
}
}
}
}
}

View File

@ -0,0 +1,14 @@
query mergeRequestTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: mergeRequest(iid: $iid) {
__typename
id
currentUserTodos(state: pending) {
nodes {
id
}
}
}
}
}

View File

@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
title: 'vue_shared/components/todo_button',
title: 'vue_shared/components/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({

View File

@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { todoLabel } from './utils';
export default {
components: {
@ -15,7 +15,7 @@ export default {
},
computed: {
buttonLabel() {
return this.isTodo ? __('Mark as done') : __('Add a to do');
return todoLabel(this.isTodo);
},
},
methods: {

View File

@ -0,0 +1,5 @@
import { __ } from '~/locale';
export const todoLabel = (hasTodo) => {
return hasTodo ? __('Mark as done') : __('Add a to do');
};

View File

@ -175,7 +175,8 @@
}
}
.block {
.block,
.issuable-sidebar-header {
@include clearfix;
padding: $gl-padding 0;
border-bottom: 1px solid $border-gray-normal;
@ -184,11 +185,6 @@
width: $gutter-inner-width;
// --
&.issuable-sidebar-header {
padding-top: 0;
padding-bottom: 10px;
}
&:last-child {
border: 0;
}
@ -273,10 +269,6 @@
padding: 0 20px;
}
.issuable-sidebar-header {
padding-top: 10px;
}
&:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header {
display: none;
@ -302,7 +294,6 @@
}
.gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal;
text-align: center;
}
@ -331,20 +322,21 @@
width: $gutter-collapsed-width;
padding: 0;
.block {
.block,
.issuable-sidebar-header {
width: $gutter-collapsed-width - 2px;
padding: 0;
border-bottom: 0;
overflow: hidden;
}
.block,
.gutter-toggle,
.sidebar-collapsed-container {
&.with-sub-blocks .sub-block:hover,
&:not(.with-sub-blocks):hover {
background-color: $gray-100;
}
&.issuable-sidebar-header {
padding-top: 0;
}
}
.participants {

View File

@ -3,6 +3,7 @@
class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
include DiffHelper
include RendersNotes
include Gitlab::Cache::Helpers
before_action :commit
before_action :define_diff_vars
@ -40,7 +41,16 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
pagination_data: diffs.pagination_data
}
render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
if diff_options_hash[:paths].blank? && Feature.enabled?(:diffs_batch_render_cached, project, default_enabled: :yaml)
render_cached(
diffs,
with: PaginatedDiffSerializer.new(current_user: current_user),
cache_context: -> (_) { [diff_view, params[:w], params[:expanded], params[:per_page], params[:page]] },
**options
)
else
render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
end
end
def diffs_metadata

View File

@ -285,7 +285,7 @@ module ApplicationHelper
def page_class
class_names = []
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
class_names << 'epic-boards-page' if current_controller?(:epic_boards)
class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards)
class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class

View File

@ -50,6 +50,10 @@ module Analytics
end
end
def events_hash_code
Digest::SHA256.hexdigest("#{start_event.hash_code}-#{end_event.hash_code}")
end
def start_event_label_based?
start_event_identifier && start_event.label_based?
end

View File

@ -42,6 +42,13 @@ class DiffDiscussion < Discussion
)
end
def cache_key
[
super,
Digest::SHA1.hexdigest(position.to_json)
].join(':')
end
private
def get_params

View File

@ -18,12 +18,6 @@ module MergeRequests
end
def rebase
# Ensure Gitaly isn't already running a rebase
if source_project.repository.rebase_in_progress?(merge_request.id)
log_error(exception: nil, message: 'Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
return false
end
repository.rebase(current_user, merge_request, skip_ci: @skip_ci)
true

View File

@ -10,19 +10,13 @@
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': issuable_type }
.issuable-sidebar
.block.issuable-sidebar-header
- if signed_in
%span.issuable-header-text.hide-collapsed.float-left
= _('To Do')
.issuable-sidebar-header.gl-py-3
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- if signed_in
= render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
.js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
= form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- if signed_in
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
.block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in

View File

@ -1,15 +0,0 @@
- is_collapsed = local_assigns.fetch(:is_collapsed, false)
- has_todo = !!issuable_sidebar.dig(:current_user, :todo, :id)
- todo_button_data = issuable_todo_button_data(issuable_sidebar, is_collapsed)
- button_title = has_todo ? todo_button_data[:mark_text] : todo_button_data[:todo_text]
- button_icon = has_todo ? todo_button_data[:mark_icon] : todo_button_data[:todo_icon]
%button.issuable-todo-btn.js-issuable-todo{ type: 'button',
class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'gl-button btn btn-default issuable-header-btn float-right'),
title: button_title,
'aria-label' => button_title,
data: todo_button_data }
%span.issuable-todo-inner.js-issuable-todo-inner<
= is_collapsed ? button_icon : button_title
= loading_icon(css_class: is_collapsed ? '' : 'gl-ml-3')

View File

@ -0,0 +1,8 @@
---
name: diffs_batch_render_cached
introduced_by_url: https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1509
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334762
milestone: '14.1'
type: development
group: group::code review
default_enabled: false

View File

@ -114,7 +114,7 @@ The action is irreversible.
## Triggering a pipeline
To trigger a job you need to send a `POST` request to the GitLab API endpoint:
To trigger a pipeline you need to send a `POST` request to the GitLab API endpoint:
```plaintext
POST /projects/:id/trigger/pipeline
@ -126,7 +126,7 @@ branches or tags. The `:id` of a project can be found by
[querying the API](../../api/projects.md) or by visiting the **CI/CD**
settings page which provides self-explanatory examples.
When a rerun of a pipeline is triggered, jobs are marked as triggered `by API` in
When a rerun of a pipeline is triggered, jobs are labeled as `triggered` in
**CI/CD > Jobs**.
You can see which trigger caused a job to run by visiting the single job page.

View File

@ -26,39 +26,40 @@ A job is defined as a list of keywords that define the job's behavior.
The keywords available for jobs are:
| Keyword | Description |
| :-----------------------------------|:------------|
| [`after_script`](#after_script) | Override a set of commands that are executed after job. |
| [`allow_failure`](#allow_failure) | Allow job to fail. A failed job does not cause the pipeline to fail. |
| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. |
| [`before_script`](#before_script) | Override a set of commands that are executed before job. |
| [`cache`](#cache) | List of files that should be cached between subsequent runs. |
| [`coverage`](#coverage) | Code coverage settings for a given job. |
| [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. |
| [`environment`](#environment) | Name of an environment to which the job deploys. |
| [`except`](#only--except) | Control when jobs are not created. |
| [`extends`](#extends) | Configuration entries that this job inherits from. |
| [`image`](#image) | Use Docker images. |
| [`include`](#include) | Include external YAML files. |
| [`inherit`](#inherit) | Select which global defaults all jobs inherit. |
| [`interruptible`](#interruptible) | Defines if a job can be canceled when made redundant by a newer run. |
| [`needs`](#needs) | Execute jobs earlier than the stage ordering. |
| [`only`](#only--except) | Control when jobs are created. |
| [`pages`](#pages) | Upload the result of a job to use with GitLab Pages. |
| [`parallel`](#parallel) | How many instances of a job should be run in parallel. |
| [`release`](#release) | Instructs the runner to generate a [release](../../user/project/releases/index.md) object. |
| [`resource_group`](#resource_group) | Limit job concurrency. |
| [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. |
| [`rules`](#rules) | List of conditions to evaluate and determine selected attributes of a job, and whether or not it's created. |
| [`script`](#script) | Shell script that is executed by a runner. |
| [`secrets`](#secrets) | The CI/CD secrets the job needs. |
| [`services`](#services) | Use Docker services images. |
| [`stage`](#stage) | Defines a job stage. |
| [`tags`](#tags) | List of tags that are used to select a runner. |
| [`timeout`](#timeout) | Define a custom job-level timeout that takes precedence over the project-wide setting. |
| [`trigger`](#trigger) | Defines a downstream pipeline trigger. |
| [`variables`](#variables) | Define job variables on a job level. |
| [`when`](#when) | When to run job. |
| Keyword | Description |
| :-------------------------------------------|:------------|
| [`after_script`](#after_script) | Override a set of commands that are executed after job. |
| [`allow_failure`](#allow_failure) | Allow job to fail. A failed job does not cause the pipeline to fail. |
| [`artifacts`](#artifacts) | List of files and directories to attach to a job on success. |
| [`before_script`](#before_script) | Override a set of commands that are executed before job. |
| [`cache`](#cache) | List of files that should be cached between subsequent runs. |
| [`coverage`](#coverage) | Code coverage settings for a given job. |
| [`dast_configuration`](#dast_configuration) | Use configuration from DAST profiles on a job level. |
| [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. |
| [`environment`](#environment) | Name of an environment to which the job deploys. |
| [`except`](#only--except) | Control when jobs are not created. |
| [`extends`](#extends) | Configuration entries that this job inherits from. |
| [`image`](#image) | Use Docker images. |
| [`include`](#include) | Include external YAML files. |
| [`inherit`](#inherit) | Select which global defaults all jobs inherit. |
| [`interruptible`](#interruptible) | Defines if a job can be canceled when made redundant by a newer run. |
| [`needs`](#needs) | Execute jobs earlier than the stage ordering. |
| [`only`](#only--except) | Control when jobs are created. |
| [`pages`](#pages) | Upload the result of a job to use with GitLab Pages. |
| [`parallel`](#parallel) | How many instances of a job should be run in parallel. |
| [`release`](#release) | Instructs the runner to generate a [release](../../user/project/releases/index.md) object. |
| [`resource_group`](#resource_group) | Limit job concurrency. |
| [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. |
| [`rules`](#rules) | List of conditions to evaluate and determine selected attributes of a job, and whether or not it's created. |
| [`script`](#script) | Shell script that is executed by a runner. |
| [`secrets`](#secrets) | The CI/CD secrets the job needs. |
| [`services`](#services) | Use Docker services images. |
| [`stage`](#stage) | Defines a job stage. |
| [`tags`](#tags) | List of tags that are used to select a runner. |
| [`timeout`](#timeout) | Define a custom job-level timeout that takes precedence over the project-wide setting. |
| [`trigger`](#trigger) | Defines a downstream pipeline trigger. |
| [`variables`](#variables) | Define job variables on a job level. |
| [`when`](#when) | When to run job. |
### Unavailable names for jobs
@ -4502,6 +4503,50 @@ You can use [CI/CD variables](../variables/index.md) to configure how the runner
You can also use variables to configure how many times a runner
[attempts certain stages of job execution](../runners/configure_runners.md#job-stages-attempts).
## `dast_configuration` **(ULTIMATE)**
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5981) in GitLab 14.1.
Use the `dast_configuration` keyword to specify a site profile and scanner profile to be used in a
CI/CD configuration. Both profiles must first have been created in the project. The job's stage must
be `dast`.
**Keyword type**: Job keyword. You can use only as part of a job.
**Possible inputs**: One each of `site_profile` and `scanner_profile`.
- Use `site_profile` to specify the site profile to be used in the job.
- Use `scanner_profile` to specify the scanner profile to be used in the job.
**Example of `dast_configuration`**:
```yaml
stages:
- build
- dast
include:
- template: DAST.gitlab-ci.yml
dast:
dast_configuration:
site_profile: "Example Co"
scanner_profile: "Quick Passive Test"
```
In this example, the `dast` job extends the `dast` configuration added with the `include:` keyword
to select a specific site profile and scanner profile.
**Additional details**:
- Settings contained in either a site profile or scanner profile take precedence over those
contained in the DAST template.
**Related topics**:
- [Site profile](../../user/application_security/dast/index.md#site-profile).
- [Scanner profile](../../user/application_security/dast/index.md#scanner-profile).
## YAML-specific features
In your `.gitlab-ci.yml` file, you can use YAML-specific features like anchors (`&`), aliases (`*`),

View File

@ -47,5 +47,5 @@ To add a story:
Notes:
- Specify the `title` field of the story as the component's file path from the `javascripts/` directory,
e.g. if the component is located at `app/assets/javascripts/vue_shared/components/sidebar/todo_button.vue`, specify the `title` as
e.g. if the component is located at `app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue`, specify the `title` as
`vue_shared/components/To-do Button`. This will ensure the Storybook navigation maps closely to our internal directory structure.

View File

@ -145,6 +145,7 @@ To enable DAST to run automatically, either:
by [Auto DevOps](../../../topics/autodevops/index.md)).
- [Include the DAST template](#include-the-dast-template) in your existing
`.gitlab-ci.yml` file.
- [Configure DAST using the UI](#configure-dast-using-the-ui).
### DAST job order
@ -265,6 +266,28 @@ image. Using the `DAST_VERSION` variable, you can choose how DAST updates:
Find the latest DAST versions on the [Releases](https://gitlab.com/security-products/dast/-/releases)
page.
#### Configure DAST using the UI
You can enable or configure DAST settings using the UI. The generated settings are formatted so they
can be conveniently pasted into the `.gitlab-ci.yml` file.
1. From the project's home page, go to **Security & Compliance > Configuration**.
1. In the **Dynamic Application Security Testing (DAST)** section, select **Enable DAST** or
**Configure DAST**.
1. Select the desired **Scanner profile**, or select **Create scanner profile** and save a
scanner profile. For more details, see [scanner profiles](#scanner-profile).
1. Select the desired **Site profile**, or select **Create site profile** and save a site
profile. For more details, see [site profiles](#site-profile).
1. Select **Generate code snippet**. A modal opens with the YAML snippet corresponding to the
options you selected.
1. Do one of the following:
1. Select **Copy code only** to copy the snippet to your clipboard.
1. Select **Copy code and open `.gitlab-ci.yml` file** to copy the snippet to your clipboard. The
CI/CD Editor then opens.
1. Paste the snippet into the `.gitlab-ci.yml` file.
1. Select the **Lint** tab to confirm the edited `.gitlab-ci.yml` file is valid.
1. Select **Commit changes**.
#### Crawling web applications dependent on JavaScript
GitLab has released a new browser-based crawler, an add-on to DAST that uses a browser to crawl web applications for content. This crawler replaces the standard DAST Spider and Ajax Crawler, and uses the same authentication mechanisms as a normal DAST scan.

View File

@ -115,8 +115,16 @@ permission enables an electronic signature for approvals, such as the one define
## Security approvals in merge requests **(ULTIMATE)**
You can require that a member of your security team approves a merge request if a
merge request could introduce a vulnerability. To learn more, see
[Security approvals in merge requests](../../../application_security/index.md#security-approvals-in-merge-requests).
merge request could introduce a vulnerability.
To learn more, see [Security approvals in merge requests](../../../application_security/index.md#security-approvals-in-merge-requests).
## Code coverage check approvals **(PREMIUM)**
You can require specific approvals if a merge request would result in a decline in code test
coverage.
To learn more, see [Coverage check approval rule](../../../../ci/pipelines/settings.md#coverage-check-approval-rule).
## Related links

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module API
module Entities
class ProjectIntegration < Entities::ProjectIntegrationBasic
# Expose serialized properties
expose :properties do |integration, options|
# TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
attributes =
if integration.data_fields_present?
integration.data_fields.as_json.keys
else
integration.properties.keys
end
attributes &= integration.api_field_names
attributes.each_with_object({}) do |attribute, hash|
hash[attribute] = integration.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
end
end

View File

@ -2,10 +2,10 @@
module API
module Entities
class ProjectServiceBasic < Grape::Entity
class ProjectIntegrationBasic < Grape::Entity
expose :id, :title
expose :slug do |service|
service.to_param.dasherize
expose :slug do |integration|
integration.to_param.dasherize
end
expose :created_at, :updated_at, :active
expose :commit_events, :push_events, :issues_events, :confidential_issues_events

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
module API
module Entities
class ProjectService < Entities::ProjectServiceBasic
# Expose serialized properties
expose :properties do |service, options|
# TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
attributes =
if service.data_fields_present?
service.data_fields.as_json.keys
else
service.properties.keys
end
attributes &= service.api_field_names
attributes.each_with_object({}) do |attribute, hash|
hash[attribute] = service.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
end
end

View File

@ -35,7 +35,7 @@ module API
end
end
TRIGGER_SERVICES = {
TRIGGER_INTEGRATIONS = {
'mattermost-slash-commands' => [
{
name: :token,
@ -60,24 +60,24 @@ module API
before { authorize_admin_project }
helpers do
def service_attributes(service)
service.fields.inject([]) do |arr, hash|
def integration_attributes(integration)
integration.fields.inject([]) do |arr, hash|
arr << hash[:name].to_sym
end
end
end
desc 'Get all active project services' do
success Entities::ProjectServiceBasic
desc 'Get all active project integrations' do
success Entities::ProjectIntegrationBasic
end
get ":id/services" do
services = user_project.integrations.active
integrations = user_project.integrations.active
present services, with: Entities::ProjectServiceBasic
present integrations, with: Entities::ProjectIntegrationBasic
end
INTEGRATIONS.each do |slug, settings|
desc "Set #{slug} service for project"
desc "Set #{slug} integration for project"
params do
settings.each do |setting|
if setting[:required]
@ -92,7 +92,7 @@ module API
params = declared_params(include_missing: false).merge(active: true)
if integration.update(params)
present integration, with: Entities::ProjectService
present integration, with: Entities::ProjectIntegration
else
render_api_error!('400 Bad Request', 400)
end
@ -107,14 +107,14 @@ module API
integration = user_project.find_or_initialize_integration(params[:slug].underscore)
destroy_conditionally!(integration) do
attrs = service_attributes(integration).index_with { nil }.merge(active: false)
attrs = integration_attributes(integration).index_with { nil }.merge(active: false)
render_api_error!('400 Bad Request', 400) unless integration.update(attrs)
end
end
desc 'Get the integration settings for a project' do
success Entities::ProjectService
success Entities::ProjectIntegration
end
params do
requires :slug, type: String, values: INTEGRATIONS.keys, desc: 'The name of the service'
@ -124,15 +124,15 @@ module API
not_found!('Service') unless integration&.persisted?
present integration, with: Entities::ProjectService
present integration, with: Entities::ProjectIntegration
end
end
TRIGGER_SERVICES.each do |service_slug, settings|
TRIGGER_INTEGRATIONS.each do |integration_slug, settings|
helpers do
def slash_command_service(project, service_slug, params)
project.integrations.active.find do |service|
service.try(:token) == params[:token] && service.to_param == service_slug.underscore
def slash_command_integration(project, integration_slug, params)
project.integrations.active.find do |integration|
integration.try(:token) == params[:token] && integration.to_param == integration_slug.underscore
end
end
end
@ -141,7 +141,7 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc "Trigger a slash command for #{service_slug}" do
desc "Trigger a slash command for #{integration_slug}" do
detail 'Added in GitLab 8.13'
end
params do
@ -149,14 +149,14 @@ module API
requires setting[:name], type: setting[:type], desc: setting[:desc]
end
end
post ":id/services/#{service_slug.underscore}/trigger" do
post ":id/services/#{integration_slug.underscore}/trigger" do
project = find_project(params[:id])
# This is not accurate, but done to prevent leakage of the project names
not_found!('Service') unless project
service = slash_command_service(project, service_slug, params)
result = service.try(:trigger, params)
integration = slash_command_integration(project, integration_slug, params)
result = integration.try(:trigger, params)
if result
status result[:status] || 200

View File

@ -31,6 +31,10 @@ module Gitlab
raise NotImplementedError
end
def hash_code
Digest::SHA256.hexdigest(self.class.identifier.to_s)
end
# Each StageEvent must expose a timestamp or a timestamp like expression in order to build a range query.
# Example: get me all the Issue records between start event end end event
def timestamp_projection

View File

@ -7,9 +7,9 @@ module Gitlab
ERROR_MESSAGE = 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".'
def validate!
# This feature flag is used for disabling integrify check on some envs
# This feature flag is used for disabling integrity check on some envs
# because these costy calculations may cause performance issues
return unless Feature.enabled?(:lfs_check, default_enabled: true)
return unless Feature.enabled?(:lfs_check, project, default_enabled: :yaml)
return unless project.lfs_enabled?

View File

@ -10,6 +10,10 @@ module Gitlab
diff_options: diff_options,
diff_refs: commit.diff_refs)
end
def cache_key
['commit', @diffable.id]
end
end
end
end

View File

@ -14,6 +14,10 @@ module Gitlab
def unfold_diff_lines(positions)
# no-op
end
def cache_key
['compare', @diffable.head.id, @diffable.base.id]
end
end
end
end

View File

@ -6,6 +6,8 @@ module Gitlab
class MergeRequestDiffBase < Base
extend ::Gitlab::Utils::Override
delegate :real_size, :overflow?, :cache_key, to: :@merge_request_diff
def initialize(merge_request_diff, diff_options:)
@merge_request_diff = merge_request_diff
@ -44,14 +46,6 @@ module Gitlab
diff_stats_cache.clear
end
def real_size
@merge_request_diff.real_size
end
def overflow?
@merge_request_diff.overflow?
end
private
def highlight_cache
@ -62,7 +56,7 @@ module Gitlab
def diff_stats_cache
strong_memoize(:diff_stats_cache) do
Gitlab::Diff::StatsCache.new(cachable_key: @merge_request_diff.cache_key)
Gitlab::Diff::StatsCache.new(cachable_key: cache_key)
end
end

View File

@ -883,12 +883,6 @@ module Gitlab
end
end
def rebase_in_progress?(rebase_id)
wrapped_gitaly_errors do
gitaly_repository_client.rebase_in_progress?(rebase_id)
end
end
def squash(user, squash_id, start_sha:, end_sha:, author:, message:)
wrapped_gitaly_errors do
gitaly_operation_client.user_squash(user, squash_id, start_sha, end_sha, author, message)

View File

@ -152,23 +152,6 @@ module Gitlab
)
end
def rebase_in_progress?(rebase_id)
request = Gitaly::IsRebaseInProgressRequest.new(
repository: @gitaly_repo,
rebase_id: rebase_id.to_s
)
response = GitalyClient.call(
@storage,
:repository_service,
:is_rebase_in_progress,
request,
timeout: GitalyClient.fast_timeout
)
response.in_progress
end
def squash_in_progress?(squash_id)
request = Gitaly::IsSquashInProgressRequest.new(
repository: @gitaly_repo,

View File

@ -10,7 +10,8 @@ import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.v
import { ISSUABLE } from '~/boards/constants';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
describe('BoardContentSidebar', () => {
let wrapper;
@ -26,7 +27,7 @@ describe('BoardContentSidebar', () => {
},
getters: {
activeBoardItem: () => {
return { ...mockIssue, epic: null };
return { ...mockActiveIssue, epic: null };
},
groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath,
@ -110,6 +111,10 @@ describe('BoardContentSidebar', () => {
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
});
it('renders SidebarTodoWidget', () => {
expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
});
@ -147,7 +152,7 @@ describe('BoardContentSidebar', () => {
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: { ...mockIssue, epic: null },
boardItem: { ...mockActiveIssue, epic: null },
sidebarType: ISSUABLE,
});
});

View File

@ -1,5 +1,6 @@
import { GlAlert } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Draggable from 'vuedraggable';
import Vuex from 'vuex';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
@ -8,8 +9,7 @@ import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.v
import BoardContent from '~/boards/components/board_content.vue';
import { mockLists, mockListsWithModel } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
const actions = {
moveList: jest.fn(),
@ -44,7 +44,6 @@ describe('BoardContent', () => {
...state,
});
wrapper = shallowMount(BoardContent, {
localVue,
propsData: {
lists: mockListsWithModel,
disabled: false,

View File

@ -182,6 +182,7 @@ export const mockIssue = {
export const mockActiveIssue = {
...mockIssue,
fullId: 'gid://gitlab/Issue/436',
id: 436,
iid: '27',
subscribed: false,

View File

@ -1,171 +0,0 @@
/* eslint-disable no-new */
import MockAdapter from 'axios-mock-adapter';
import { clone } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import Sidebar from '~/right_sidebar';
import { fixTitle } from '~/tooltips';
jest.mock('~/tooltips');
describe('Issuable right sidebar collapsed todo toggle', () => {
const fixtureName = 'issues/open-issue.html';
const jsonFixtureName = 'todos/todos.json';
let mock;
beforeEach(() => {
const todoData = getJSONFixture(jsonFixtureName);
new Sidebar();
loadFixtures(fixtureName);
document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-expanded');
document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-collapsed');
mock = new MockAdapter(axios);
mock.onPost(`${TEST_HOST}/frontend-fixtures/issues-project/todos`).reply(() => {
const response = clone(todoData);
return [200, response];
});
mock.onDelete(/(.*)\/dashboard\/todos\/\d+$/).reply(() => {
const response = clone(todoData);
delete response.delete_path;
return [200, response];
});
});
afterEach(() => {
mock.restore();
});
it('shows add todo button', () => {
expect(document.querySelector('.js-issuable-todo.sidebar-collapsed-icon')).not.toBeNull();
expect(
document
.querySelector('.js-issuable-todo.sidebar-collapsed-icon svg')
.getAttribute('data-testid'),
).toBe('todo-add-icon');
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
).toBeNull();
});
it('sets default tooltip title', () => {
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('title'),
).toBe('Add a to do');
});
it('toggle todo state', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
setImmediate(() => {
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
).not.toBeNull();
expect(
document
.querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone')
.getAttribute('data-testid'),
).toBe('todo-done-icon');
done();
});
});
it('toggle todo state of expanded todo toggle', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
setImmediate(() => {
expect(
document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
).toBe('Mark as done');
done();
});
});
it('toggles todo button tooltip', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
setImmediate(() => {
const el = document.querySelector('.js-issuable-todo.sidebar-collapsed-icon');
expect(el.getAttribute('title')).toBe('Mark as done');
expect(fixTitle).toHaveBeenCalledWith(el);
done();
});
});
it('marks todo as done', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
waitForPromises()
.then(() => {
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
).not.toBeNull();
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
})
.then(waitForPromises)
.then(() => {
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
).toBeNull();
expect(
document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
).toBe('Add a to do');
})
.then(done)
.catch(done.fail);
});
it('updates aria-label to Mark as done', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
setImmediate(() => {
expect(
document
.querySelector('.js-issuable-todo.sidebar-collapsed-icon')
.getAttribute('aria-label'),
).toBe('Mark as done');
done();
});
});
it('updates aria-label to add todo', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
waitForPromises()
.then(() => {
expect(
document
.querySelector('.js-issuable-todo.sidebar-collapsed-icon')
.getAttribute('aria-label'),
).toBe('Mark as done');
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
})
.then(waitForPromises)
.then(() => {
expect(
document
.querySelector('.js-issuable-todo.sidebar-collapsed-icon')
.getAttribute('aria-label'),
).toBe('Add a to do');
})
.then(done)
.catch(done.fail);
});
});

View File

@ -2,7 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import DesignTodoButton from '~/design_management/components/design_todo_button.vue';
import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql';
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
import TodoButton from '~/vue_shared/components/sidebar/todo_button.vue';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
import mockDesign from '../mock_data/design';
const mockDesignWithPendingTodos = {

View File

@ -66,22 +66,6 @@ describe('RightSidebar', () => {
assertSidebarState('collapsed');
});
it('should broadcast todo:toggle event when add todo clicked', (done) => {
const todos = getJSONFixture('todos/todos.json');
mock.onPost(/(.*)\/todos$/).reply(200, todos);
const todoToggleSpy = jest.fn();
$(document).on('todo:toggle', todoToggleSpy);
$('.issuable-sidebar-header .js-issuable-todo').click();
setImmediate(() => {
expect(todoToggleSpy.mock.calls.length).toEqual(1);
done();
});
});
it('should not hide collapsed icons', () => {
[].forEach.call(document.querySelectorAll('.sidebar-collapsed-icon'), (el) => {
expect(el.querySelector('.fa, svg').classList.contains('hidden')).toBeFalsy();

View File

@ -1,3 +1,4 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@ -6,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
import TodoButton from '~/vue_shared/components/sidebar/todo_button.vue';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
import { todosResponse, noTodosResponse } from '../../mock_data';
jest.mock('~/flash');
@ -28,6 +29,7 @@ describe('Sidebar Todo Widget', () => {
apolloProvider: fakeApollo,
provide: {
canUpdate: true,
isClassicSidebar: true,
},
propsData: {
fullPath: 'group',
@ -83,4 +85,42 @@ describe('Sidebar Todo Widget', () => {
expect(createFlash).toHaveBeenCalled();
});
describe('collapsed', () => {
const event = { stopPropagation: jest.fn(), preventDefault: jest.fn() };
beforeEach(() => {
createComponent({
todosQueryHandler: jest.fn().mockResolvedValue(noTodosResponse),
});
return waitForPromises();
});
it('shows add todo icon', () => {
expect(wrapper.find(GlIcon).exists()).toBe(true);
expect(wrapper.find(GlIcon).props('name')).toBe('todo-add');
});
it('sets default tooltip title', () => {
expect(wrapper.find(GlIcon).attributes('title')).toBe('Add a to do');
});
it('when user has a to do', async () => {
createComponent({
todosQueryHandler: jest.fn().mockResolvedValue(todosResponse),
});
await waitForPromises();
expect(wrapper.find(GlIcon).props('name')).toBe('todo-done');
expect(wrapper.find(GlIcon).attributes('title')).toBe('Mark as done');
});
it('emits `todoUpdated` event on click on icon', async () => {
wrapper.find(GlIcon).vm.$emit('click', event);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('todoUpdated')).toEqual([[false]]);
});
});
});

View File

@ -1,6 +1,6 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import TodoButton from '~/vue_shared/components/sidebar/todo_button.vue';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
describe('Todo Button', () => {
let wrapper;

View File

@ -75,4 +75,12 @@ RSpec.describe Gitlab::Diff::FileCollection::Commit do
]
end
end
describe '#cache_key' do
subject(:cache_key) { described_class.new(diffable, diff_options: nil).cache_key }
it 'returns with the commit id' do
expect(cache_key).to eq ['commit', diffable.id]
end
end
end

View File

@ -15,29 +15,20 @@ RSpec.describe Gitlab::Diff::FileCollection::Compare do
head_commit.id)
end
it_behaves_like 'diff statistics' do
let(:collection_default_args) do
{
project: diffable.project,
diff_options: {},
diff_refs: diffable.diff_refs
}
end
let(:diffable) { Compare.new(raw_compare, project) }
let(:collection_default_args) do
{
project: diffable.project,
diff_options: {},
diff_refs: diffable.diff_refs
}
end
let(:diffable) { Compare.new(raw_compare, project) }
it_behaves_like 'diff statistics' do
let(:stub_path) { '.gitignore' }
end
it_behaves_like 'sortable diff files' do
let(:diffable) { Compare.new(raw_compare, project) }
let(:collection_default_args) do
{
project: diffable.project,
diff_options: {},
diff_refs: diffable.diff_refs
}
end
let(:unsorted_diff_files_paths) do
[
'.DS_Store',
@ -66,4 +57,12 @@ RSpec.describe Gitlab::Diff::FileCollection::Compare do
]
end
end
describe '#cache_key' do
subject(:cache_key) { described_class.new(diffable, **collection_default_args).cache_key }
it 'returns with head and base' do
expect(cache_key).to eq ['compare', head_commit.id, start_commit.id]
end
end
end

View File

@ -25,4 +25,12 @@ RSpec.describe Gitlab::Diff::FileCollection::MergeRequestDiffBase do
end
end
end
describe '#cache_key' do
subject(:cache_key) { described_class.new(diffable, diff_options: nil).cache_key }
it 'returns cache_key from merge_request_diff' do
expect(cache_key).to eq diffable.cache_key
end
end
end

View File

@ -209,19 +209,6 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
end
end
describe '#rebase_in_progress?' do
let(:rebase_id) { 1 }
it 'sends a repository_rebase_in_progress message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
.to receive(:is_rebase_in_progress)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(in_progress: true))
client.rebase_in_progress?(rebase_id)
end
end
describe '#squash_in_progress?' do
let(:squash_id) { 1 }

View File

@ -126,4 +126,13 @@ RSpec.describe DiffDiscussion do
end
end
end
describe '#cache_key' do
it 'returns the cache key with the position sha' do
notes_sha = Digest::SHA1.hexdigest("#{diff_note.id}")
position_sha = Digest::SHA1.hexdigest(diff_note.position.to_json)
expect(subject.cache_key).to eq("#{described_class::CACHE_VERSION}:#{diff_note.latest_cached_markdown_version}:#{subject.id}:#{notes_sha}:#{diff_note.updated_at}::#{position_sha}")
end
end
end

View File

@ -31,7 +31,7 @@ RSpec.describe API::Services do
it "returns a list of all active integrations" do
get api("/projects/#{project.id}/services", user)
aggregate_failures 'expect successful response with all active services' do
aggregate_failures 'expect successful response with all active integrations' do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.count).to eq(1)
@ -42,40 +42,38 @@ RSpec.describe API::Services do
end
end
Integration.available_integration_names.each do |service|
describe "PUT /projects/:id/services/#{service.dasherize}" do
include_context service
Integration.available_integration_names.each do |integration|
describe "PUT /projects/:id/services/#{integration.dasherize}" do
include_context integration
it "updates #{service} settings" do
put api("/projects/#{project.id}/services/#{dashed_service}", user), params: service_attrs
it "updates #{integration} settings" do
put api("/projects/#{project.id}/services/#{dashed_integration}", user), params: integration_attrs
expect(response).to have_gitlab_http_status(:ok)
current_service = project.integrations.first
events = current_service.event_names.empty? ? ["foo"].freeze : current_service.event_names
current_integration = project.integrations.first
events = current_integration.event_names.empty? ? ["foo"].freeze : current_integration.event_names
query_strings = []
events.each do |event|
query_strings << "#{event}=#{!current_service[event]}"
query_strings << "#{event}=#{!current_integration[event]}"
end
query_strings = query_strings.join('&')
put api("/projects/#{project.id}/services/#{dashed_service}?#{query_strings}", user), params: service_attrs
put api("/projects/#{project.id}/services/#{dashed_integration}?#{query_strings}", user), params: integration_attrs
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['slug']).to eq(dashed_service)
expect(json_response['slug']).to eq(dashed_integration)
events.each do |event|
next if event == "foo"
expect(project.integrations.first[event]).not_to eq(current_service[event]),
"expected #{!current_service[event]} for event #{event} for service #{current_service.title}, got #{current_service[event]}"
expect(project.integrations.first[event]).not_to eq(current_integration[event]),
"expected #{!current_integration[event]} for event #{event} for service #{current_integration.title}, got #{current_integration[event]}"
end
end
it "returns if required fields missing" do
attrs = service_attrs
required_attributes = service_attrs_list.select do |attr|
service_klass.validators_on(attr).any? do |v|
required_attributes = integration_attrs_list.select do |attr|
integration_klass.validators_on(attr).any? do |v|
v.instance_of?(ActiveRecord::Validations::PresenceValidator) &&
# exclude presence validators with conditional since those are not really required
![:if, :unless].any? { |cond| v.options.include?(cond) }
@ -85,74 +83,74 @@ RSpec.describe API::Services do
if required_attributes.empty?
expected_code = :ok
else
attrs.delete(required_attributes.sample)
integration_attrs.delete(required_attributes.sample)
expected_code = :bad_request
end
put api("/projects/#{project.id}/services/#{dashed_service}", user), params: attrs
put api("/projects/#{project.id}/services/#{dashed_integration}", user), params: integration_attrs
expect(response).to have_gitlab_http_status(expected_code)
end
end
describe "DELETE /projects/:id/services/#{service.dasherize}" do
include_context service
describe "DELETE /projects/:id/services/#{integration.dasherize}" do
include_context integration
before do
initialize_integration(service)
initialize_integration(integration)
end
it "deletes #{service}" do
delete api("/projects/#{project.id}/services/#{dashed_service}", user)
it "deletes #{integration}" do
delete api("/projects/#{project.id}/services/#{dashed_integration}", user)
expect(response).to have_gitlab_http_status(:no_content)
project.send(service_method).reload
expect(project.send(service_method).activated?).to be_falsey
project.send(integration_method).reload
expect(project.send(integration_method).activated?).to be_falsey
end
end
describe "GET /projects/:id/services/#{service.dasherize}" do
include_context service
describe "GET /projects/:id/services/#{integration.dasherize}" do
include_context integration
let!(:initialized_service) { initialize_integration(service, active: true) }
let!(:initialized_integration) { initialize_integration(integration, active: true) }
let_it_be(:project2) do
create(:project, creator_id: user.id, namespace: user.namespace)
end
def deactive_service!
return initialized_service.update!(active: false) unless initialized_service.is_a?(::Integrations::Prometheus)
def deactive_integration!
return initialized_integration.update!(active: false) unless initialized_integration.is_a?(::Integrations::Prometheus)
# Integrations::Prometheus sets `#active` itself within a `before_save`:
initialized_service.manual_configuration = false
initialized_service.save!
initialized_integration.manual_configuration = false
initialized_integration.save!
end
it 'returns authentication error when unauthenticated' do
get api("/projects/#{project.id}/services/#{dashed_service}")
get api("/projects/#{project.id}/services/#{dashed_integration}")
expect(response).to have_gitlab_http_status(:unauthorized)
end
it "returns all properties of active service #{service}" do
get api("/projects/#{project.id}/services/#{dashed_service}", user)
it "returns all properties of active service #{integration}" do
get api("/projects/#{project.id}/services/#{dashed_integration}", user)
expect(initialized_service).to be_active
expect(initialized_integration).to be_active
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['properties'].keys).to match_array(service_instance.api_field_names)
expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names)
end
it "returns all properties of inactive integration #{service}" do
deactive_service!
it "returns all properties of inactive integration #{integration}" do
deactive_integration!
get api("/projects/#{project.id}/services/#{dashed_service}", user)
get api("/projects/#{project.id}/services/#{dashed_integration}", user)
expect(initialized_service).not_to be_active
expect(initialized_integration).not_to be_active
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['properties'].keys).to match_array(service_instance.api_field_names)
expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names)
end
it "returns not found if integration does not exist" do
get api("/projects/#{project2.id}/services/#{dashed_service}", user)
get api("/projects/#{project2.id}/services/#{dashed_integration}", user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Service Not Found')
@ -160,10 +158,10 @@ RSpec.describe API::Services do
it "returns not found if service exists but is in `Project#disabled_integrations`" do
expect_next_found_instance_of(Project) do |project|
expect(project).to receive(:disabled_integrations).at_least(:once).and_return([service])
expect(project).to receive(:disabled_integrations).at_least(:once).and_return([integration])
end
get api("/projects/#{project.id}/services/#{dashed_service}", user)
get api("/projects/#{project.id}/services/#{dashed_integration}", user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Service Not Found')
@ -171,7 +169,7 @@ RSpec.describe API::Services do
it "returns error when authenticated but not a project owner" do
project.add_developer(user2)
get api("/projects/#{project.id}/services/#{dashed_service}", user2)
get api("/projects/#{project.id}/services/#{dashed_integration}", user2)
expect(response).to have_gitlab_http_status(:forbidden)
end
@ -235,8 +233,8 @@ RSpec.describe API::Services do
end
end
describe 'Slack Service' do
let(:service_name) { 'slack_slash_commands' }
describe 'Slack Integration' do
let(:integration_name) { 'slack_slash_commands' }
before do
project.create_slack_slash_commands_integration(
@ -246,7 +244,7 @@ RSpec.describe API::Services do
end
it 'returns status 200' do
post api("/projects/#{project.id}/services/#{service_name}/trigger"), params: { token: 'token', text: 'help' }
post api("/projects/#{project.id}/services/#{integration_name}/trigger"), params: { token: 'token', text: 'help' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['response_type']).to eq("ephemeral")
@ -309,8 +307,8 @@ RSpec.describe API::Services do
end
end
describe 'Hangouts Chat service' do
let(:service_name) { 'hangouts-chat' }
describe 'Hangouts Chat integration' do
let(:integration_name) { 'hangouts-chat' }
let(:params) do
{
webhook: 'https://hook.example.com',
@ -326,21 +324,21 @@ RSpec.describe API::Services do
end
it 'accepts branches_to_be_notified for update', :aggregate_failures do
put api("/projects/#{project.id}/services/#{service_name}", user), params: params.merge(branches_to_be_notified: 'all')
put api("/projects/#{project.id}/services/#{integration_name}", user), params: params.merge(branches_to_be_notified: 'all')
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['properties']['branches_to_be_notified']).to eq('all')
end
it 'only requires the webhook param' do
put api("/projects/#{project.id}/services/#{service_name}", user), params: { webhook: 'https://hook.example.com' }
put api("/projects/#{project.id}/services/#{integration_name}", user), params: { webhook: 'https://hook.example.com' }
expect(response).to have_gitlab_http_status(:ok)
end
end
describe 'Pipelines Email Integration' do
let(:service_name) { 'pipelines-email' }
let(:integration_name) { 'pipelines-email' }
context 'notify_only_broken_pipelines property was saved as a string' do
before do
@ -354,7 +352,7 @@ RSpec.describe API::Services do
end
it 'returns boolean values for notify_only_broken_pipelines' do
get api("/projects/#{project.id}/services/#{service_name}", user)
get api("/projects/#{project.id}/services/#{integration_name}", user)
expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true)
end

View File

@ -0,0 +1,126 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge Requests Diffs' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
before do
project.add_maintainer(user)
sign_in(user)
end
describe 'GET diffs_batch' do
let(:headers) { {} }
shared_examples_for 'serializes diffs with expected arguments' do
it 'serializes paginated merge request diff collection' do
expect_next_instance_of(PaginatedDiffSerializer) do |instance|
expect(instance).to receive(:represent)
.with(an_instance_of(collection), expected_options)
.and_call_original
end
subject
end
end
def collection_arguments(pagination_data = {})
{
environment: nil,
merge_request: merge_request,
diff_view: :inline,
merge_ref_head_diff: nil,
pagination_data: {
total_pages: nil
}.merge(pagination_data)
}
end
def go(extra_params = {})
params = {
namespace_id: project.namespace.to_param,
project_id: project,
id: merge_request.iid,
page: 0,
per_page: 20,
format: 'json'
}
get diffs_batch_namespace_project_json_merge_request_path(params.merge(extra_params)), headers: headers
end
context 'with caching', :use_clean_rails_memory_store_caching do
subject { go(page: 0, per_page: 5) }
context 'when the request has not been cached' do
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
let(:expected_options) { collection_arguments(total_pages: 20) }
end
end
context 'when the request has already been cached' do
before do
go(page: 0, per_page: 5)
end
it 'does not serialize diffs' do
expect_next_instance_of(PaginatedDiffSerializer) do |instance|
expect(instance).not_to receive(:represent)
end
subject
end
context 'with the different pagination option' do
subject { go(page: 5, per_page: 5) }
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
let(:expected_options) { collection_arguments(total_pages: 20) }
end
end
context 'with the different diff_view' do
subject { go(page: 0, per_page: 5, view: :parallel) }
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
let(:expected_options) { collection_arguments(total_pages: 20).merge(diff_view: :parallel) }
end
end
context 'with the different expanded option' do
subject { go(page: 0, per_page: 5, expanded: true ) }
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
let(:expected_options) { collection_arguments(total_pages: 20) }
end
end
context 'with the different ignore_whitespace_change option' do
subject { go(page: 0, per_page: 5, w: 1) }
it_behaves_like 'serializes diffs with expected arguments' do
let(:collection) { Gitlab::Diff::FileCollection::Compare }
let(:expected_options) { collection_arguments(total_pages: 20) }
end
end
end
context 'when the paths is given' do
subject { go(page: 0, per_page: 5, paths: %w[README CHANGELOG]) }
it 'does not use cache' do
expect(Rails.cache).not_to receive(:fetch).with(/cache:gitlab:PaginatedDiffSerializer/).and_call_original
subject
end
end
end
end
end

View File

@ -158,6 +158,29 @@ RSpec.describe 'merge requests discussions' do
end
end
context 'when the diff note position changes' do
before do
# This replicates a position change wherein timestamps aren't updated
# which is why `Gitlab::Timeless.timeless` is utilized. This is the
# same approach being used in Discussions::UpdateDiffPositionService
# which is responsible for updating the positions of diff discussions
# when MR updates.
first_note.position = Gitlab::Diff::Position.new(
old_path: first_note.position.old_path,
new_path: first_note.position.new_path,
old_line: first_note.position.old_line,
new_line: first_note.position.new_line + 1,
diff_refs: first_note.position.diff_refs
)
Gitlab::Timeless.timeless(first_note, &:save)
end
it_behaves_like 'cache miss' do
let(:changed_notes) { [first_note, second_note] }
end
end
context 'when merge_request_discussion_cache is disabled' do
before do
stub_feature_flags(merge_request_discussion_cache: false)

View File

@ -25,30 +25,6 @@ RSpec.describe MergeRequests::RebaseService do
end
describe '#execute' do
context 'when another rebase is already in progress' do
before do
allow(repository).to receive(:rebase_in_progress?).with(merge_request.id).and_return(true)
end
it 'saves the error message' do
service.execute(merge_request)
expect(merge_request.reload.merge_error).to eq 'Rebase task canceled: Another rebase is already in progress'
end
it 'returns an error' do
expect(service.execute(merge_request)).to match(status: :error,
message: described_class::REBASE_ERROR)
end
it 'clears rebase_jid' do
expect { service.execute(merge_request) }
.to change { merge_request.rebase_jid }
.from(rebase_jid)
.to(nil)
end
end
shared_examples 'sequence of failure and success' do
it 'properly clears the error message' do
allow(repository).to receive(:gitaly_operation_client).and_raise('Something went wrong')
@ -150,6 +126,13 @@ RSpec.describe MergeRequests::RebaseService do
it_behaves_like 'a service that can execute a successful rebase'
it 'clears rebase_jid' do
expect { service.execute(merge_request) }
.to change(merge_request, :rebase_jid)
.from(rebase_jid)
.to(nil)
end
context 'when skip_ci flag is set' do
let(:skip_ci) { true }

View File

@ -1,38 +1,38 @@
# frozen_string_literal: true
Integration.available_integration_names.each do |service|
RSpec.shared_context service do
include JiraServiceHelper if service == 'jira'
Integration.available_integration_names.each do |integration|
RSpec.shared_context integration do
include JiraServiceHelper if integration == 'jira'
let(:dashed_service) { service.dasherize }
let(:service_method) { Project.integration_association_name(service) }
let(:service_klass) { Integration.integration_name_to_model(service) }
let(:service_instance) { service_klass.new }
let(:service_fields) { service_instance.fields }
let(:service_attrs_list) { service_fields.inject([]) {|arr, hash| arr << hash[:name].to_sym } }
let(:service_attrs) do
service_attrs_list.inject({}) do |hash, k|
let(:dashed_integration) { integration.dasherize }
let(:integration_method) { Project.integration_association_name(integration) }
let(:integration_klass) { Integration.integration_name_to_model(integration) }
let(:integration_instance) { integration_klass.new }
let(:integration_fields) { integration_instance.fields }
let(:integration_attrs_list) { integration_fields.inject([]) {|arr, hash| arr << hash[:name].to_sym } }
let(:integration_attrs) do
integration_attrs_list.inject({}) do |hash, k|
if k =~ /^(token*|.*_token|.*_key)/
hash.merge!(k => 'secrettoken')
elsif service == 'confluence' && k == :confluence_url
elsif integration == 'confluence' && k == :confluence_url
hash.merge!(k => 'https://example.atlassian.net/wiki')
elsif service == 'datadog' && k == :datadog_site
elsif integration == 'datadog' && k == :datadog_site
hash.merge!(k => 'datadoghq.com')
elsif service == 'packagist' && k == :server
elsif integration == 'packagist' && k == :server
hash.merge!(k => 'https://packagist.example.com')
elsif k =~ /^(.*_url|url|webhook)/
hash.merge!(k => "http://example.com")
elsif service_klass.method_defined?("#{k}?")
elsif integration_klass.method_defined?("#{k}?")
hash.merge!(k => true)
elsif service == 'irker' && k == :recipients
elsif integration == 'irker' && k == :recipients
hash.merge!(k => 'irc://irc.network.net:666/#channel')
elsif service == 'irker' && k == :server_port
elsif integration == 'irker' && k == :server_port
hash.merge!(k => 1234)
elsif service == 'jira' && k == :jira_issue_transition_id
elsif integration == 'jira' && k == :jira_issue_transition_id
hash.merge!(k => '1,2,3')
elsif service == 'emails_on_push' && k == :recipients
elsif integration == 'emails_on_push' && k == :recipients
hash.merge!(k => 'foo@bar.com')
elsif service == 'slack' || service == 'mattermost' && k == :labels_to_be_notified_behavior
elsif integration == 'slack' || integration == 'mattermost' && k == :labels_to_be_notified_behavior
hash.merge!(k => "match_any")
else
hash.merge!(k => "someword")
@ -47,24 +47,24 @@ Integration.available_integration_names.each do |service|
end
before do
enable_license_for_service(service)
stub_jira_integration_test if service == 'jira'
enable_license_for_integration(integration)
stub_jira_integration_test if integration == 'jira'
end
def initialize_integration(integration, attrs = {})
record = project.find_or_initialize_integration(integration)
record.attributes = attrs
record.properties = service_attrs
record.properties = integration_attrs
record.save!
record
end
private
def enable_license_for_service(service)
def enable_license_for_integration(integration)
return unless respond_to?(:stub_licensed_features)
licensed_feature = licensed_features[service]
licensed_feature = licensed_features[integration]
return unless licensed_feature
stub_licensed_features(licensed_feature => true)

View File

@ -3,6 +3,7 @@
RSpec.shared_examples_for 'value stream analytics event' do
let(:params) { {} }
let(:instance) { described_class.new(params) }
let(:expected_hash_code) { Digest::SHA256.hexdigest(instance.class.identifier.to_s) }
it { expect(described_class.name).to be_a_kind_of(String) }
it { expect(described_class.identifier).to be_a_kind_of(Symbol) }
@ -19,4 +20,16 @@ RSpec.shared_examples_for 'value stream analytics event' do
expect(output_query).to be_a_kind_of(ActiveRecord::Relation)
end
end
describe '#hash_code' do
it 'returns a hash that uniquely identifies an event' do
expect(instance.hash_code).to eq(expected_hash_code)
end
it 'does not differ when the same object is built with the same params' do
another_instance_with_same_params = described_class.new(params)
expect(another_instance_with_same_params.hash_code).to eq(instance.hash_code)
end
end
end

View File

@ -122,6 +122,22 @@ RSpec.shared_examples 'value stream analytics stage' do
expect(stage.parent_id).to eq(parent.id)
end
end
describe '#hash_code' do
it 'does not differ when the same object is built with the same params' do
stage_1 = build(factory)
stage_2 = build(factory)
expect(stage_1.events_hash_code).to eq(stage_2.events_hash_code)
end
it 'differs when the stage events are different' do
stage_1 = build(factory, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
stage_2 = build(factory, start_event_identifier: :issue_created, end_event_identifier: :issue_first_mentioned_in_commit)
expect(stage_1.events_hash_code).not_to eq(stage_2.events_hash_code)
end
end
end
RSpec.shared_examples 'value stream analytics label based stage' do