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" - "config.ru"
# List explicitly all the app/ dirs that are backend (i.e. all except app/assets). # 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/}{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" - "{,ee/,jh/}spec/**/*.rb"
# CI changes # CI changes
- ".gitlab-ci.yml" - ".gitlab-ci.yml"
@ -239,7 +239,7 @@
- "Rakefile" - "Rakefile"
- "tests.yml" - "tests.yml"
- "config.ru" - "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 - "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml" - "data/whats_new/*.yml"
@ -264,7 +264,7 @@
- "Rakefile" - "Rakefile"
- "tests.yml" - "tests.yml"
- "config.ru" - "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 - "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml" - "data/whats_new/*.yml"
# Backstage changes # Backstage changes
@ -292,7 +292,7 @@
- "Rakefile" - "Rakefile"
- "tests.yml" - "tests.yml"
- "config.ru" - "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 - "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml" - "data/whats_new/*.yml"
# QA changes # QA changes
@ -316,7 +316,7 @@
- "Rakefile" - "Rakefile"
- "tests.yml" - "tests.yml"
- "config.ru" - "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 - "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml" - "data/whats_new/*.yml"
# Backstage changes # 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 SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_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'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
@ -24,6 +25,7 @@ export default {
BoardSidebarLabelsSelect, BoardSidebarLabelsSelect,
SidebarSubscriptionsWidget, SidebarSubscriptionsWidget,
SidebarDropdownWidget, SidebarDropdownWidget,
SidebarTodoWidget,
MountingPortal, MountingPortal,
SidebarWeightWidget: () => SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
@ -90,6 +92,15 @@ export default {
<template #title> <template #title>
<h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2> <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2>
</template> </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> <template #default>
<board-sidebar-title /> <board-sidebar-title />
<sidebar-assignees-widget <sidebar-assignees-widget

View File

@ -1,6 +1,6 @@
<script> <script>
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.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 createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql';
import getDesignQuery from '../graphql/queries/get_design.query.graphql'; import getDesignQuery from '../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../mixins/all_versions'; import allVersionsMixin from '../mixins/all_versions';

View File

@ -2,7 +2,7 @@
import $ from 'jquery'; import $ from 'jquery';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { fixTitle, hide } from '~/tooltips'; import { hide } from '~/tooltips';
import createFlash from './flash'; import createFlash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale'; 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) { Sidebar.prototype.sidebarCollapseClicked = function (e) {
if ($(e.currentTarget).hasClass('dont-change-state')) { if ($(e.currentTarget).hasClass('dont-change-state')) {
return; return;

View File

@ -1,18 +1,26 @@
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer'; import { produce } from 'immer';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; 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 { export default {
components: { components: {
GlButton,
GlIcon,
TodoButton, TodoButton,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
inject: {
isClassicSidebar: {
default: false,
},
},
props: { props: {
issuableId: { issuableId: {
type: String, type: String,
@ -86,6 +94,12 @@ export default {
} }
return TodoMutationTypes.Create; return TodoMutationTypes.Create;
}, },
collapsedButtonIcon() {
return this.hasTodo ? 'todo-done' : 'todo-add';
},
tootltipTitle() {
return todoLabel(this.hasTodo);
},
}, },
methods: { methods: {
toggleTodo() { toggleTodo() {
@ -158,7 +172,24 @@ export default {
:is-todo="hasTodo" :is-todo="hasTodo"
:loading="isLoading" :loading="isLoading"
size="small" size="small"
class="hide-collapsed"
@click.stop.prevent="toggleTodo" @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> </div>
</template> </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 issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.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 mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.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 todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql';
import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql'; import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql';
import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
@ -216,6 +218,12 @@ export const todoQueries = {
[IssuableType.Epic]: { [IssuableType.Epic]: {
query: epicTodoQuery, query: epicTodoQuery,
}, },
[IssuableType.Issue]: {
query: issueTodoQuery,
},
[IssuableType.MergeRequest]: {
query: mergeRequestTodoQuery,
},
}; };
export const TodoMutationTypes = { export const TodoMutationTypes = {

View File

@ -2,6 +2,8 @@ import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createFlash from '~/flash'; 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 initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issue_show/constants'; 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 SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_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 { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
@ -40,6 +43,40 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML); 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() { function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input'); const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl) return Array.from(sidebarAssigneeEl)
@ -497,6 +534,7 @@ export function mountSidebar(mediator) {
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger(); initInviteMembersTrigger();
mountSidebarToDoWidget();
if (isAssigneesWidgetShown) { if (isAssigneesWidgetShown) {
mountAssigneesComponent(); mountAssigneesComponent();
} else { } 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 { export default {
component: TodoButton, component: TodoButton,
title: 'vue_shared/components/todo_button', title: 'vue_shared/components/todo_toggle/todo_button',
}; };
const Template = (args, { argTypes }) => ({ const Template = (args, { argTypes }) => ({

View File

@ -1,6 +1,6 @@
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale'; import { todoLabel } from './utils';
export default { export default {
components: { components: {
@ -15,7 +15,7 @@ export default {
}, },
computed: { computed: {
buttonLabel() { buttonLabel() {
return this.isTodo ? __('Mark as done') : __('Add a to do'); return todoLabel(this.isTodo);
}, },
}, },
methods: { 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; @include clearfix;
padding: $gl-padding 0; padding: $gl-padding 0;
border-bottom: 1px solid $border-gray-normal; border-bottom: 1px solid $border-gray-normal;
@ -184,11 +185,6 @@
width: $gutter-inner-width; width: $gutter-inner-width;
// -- // --
&.issuable-sidebar-header {
padding-top: 0;
padding-bottom: 10px;
}
&:last-child { &:last-child {
border: 0; border: 0;
} }
@ -273,10 +269,6 @@
padding: 0 20px; padding: 0 20px;
} }
.issuable-sidebar-header {
padding-top: 10px;
}
&:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) { &:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header { .issuable-sidebar-header {
display: none; display: none;
@ -302,7 +294,6 @@
} }
.gutter-toggle { .gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal; border-left: 1px solid $border-gray-normal;
text-align: center; text-align: center;
} }
@ -331,20 +322,21 @@
width: $gutter-collapsed-width; width: $gutter-collapsed-width;
padding: 0; padding: 0;
.block { .block,
.issuable-sidebar-header {
width: $gutter-collapsed-width - 2px; width: $gutter-collapsed-width - 2px;
padding: 0; padding: 0;
border-bottom: 0; border-bottom: 0;
overflow: hidden; overflow: hidden;
}
.block,
.gutter-toggle,
.sidebar-collapsed-container {
&.with-sub-blocks .sub-block:hover, &.with-sub-blocks .sub-block:hover,
&:not(.with-sub-blocks):hover { &:not(.with-sub-blocks):hover {
background-color: $gray-100; background-color: $gray-100;
} }
&.issuable-sidebar-header {
padding-top: 0;
}
} }
.participants { .participants {

View File

@ -3,6 +3,7 @@
class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
include DiffHelper include DiffHelper
include RendersNotes include RendersNotes
include Gitlab::Cache::Helpers
before_action :commit before_action :commit
before_action :define_diff_vars before_action :define_diff_vars
@ -40,8 +41,17 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
pagination_data: diffs.pagination_data pagination_data: diffs.pagination_data
} }
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) render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
end end
end
def diffs_metadata def diffs_metadata
diffs = @compare.diffs(diff_options) diffs = @compare.diffs(diff_options)

View File

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

View File

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

View File

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

View File

@ -18,12 +18,6 @@ module MergeRequests
end end
def rebase 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) repository.rebase(current_user, merge_request, skip_ci: @skip_ci)
true 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 } %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 .issuable-sidebar
.block.issuable-sidebar-header .issuable-sidebar-header.gl-py-3
- if signed_in
%span.issuable-header-text.hide-collapsed.float-left
= _('To Do')
%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' } } %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 = sidebar_gutter_toggle_icon
- if signed_in - 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| = 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 .block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in = 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 ## 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 ```plaintext
POST /projects/:id/trigger/pipeline 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** [querying the API](../../api/projects.md) or by visiting the **CI/CD**
settings page which provides self-explanatory examples. 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**. **CI/CD > Jobs**.
You can see which trigger caused a job to run by visiting the single job page. You can see which trigger caused a job to run by visiting the single job page.

View File

@ -27,13 +27,14 @@ A job is defined as a list of keywords that define the job's behavior.
The keywords available for jobs are: The keywords available for jobs are:
| Keyword | Description | | Keyword | Description |
| :-----------------------------------|:------------| | :-------------------------------------------|:------------|
| [`after_script`](#after_script) | Override a set of commands that are executed after job. | | [`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. | | [`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. | | [`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. | | [`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. | | [`cache`](#cache) | List of files that should be cached between subsequent runs. |
| [`coverage`](#coverage) | Code coverage settings for a given job. | | [`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. | | [`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. | | [`environment`](#environment) | Name of an environment to which the job deploys. |
| [`except`](#only--except) | Control when jobs are not created. | | [`except`](#only--except) | Control when jobs are not created. |
@ -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 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). [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 ## YAML-specific features
In your `.gitlab-ci.yml` file, you can use YAML-specific features like anchors (`&`), aliases (`*`), 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: Notes:
- Specify the `title` field of the story as the component's file path from the `javascripts/` directory, - 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. `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)). by [Auto DevOps](../../../topics/autodevops/index.md)).
- [Include the DAST template](#include-the-dast-template) in your existing - [Include the DAST template](#include-the-dast-template) in your existing
`.gitlab-ci.yml` file. `.gitlab-ci.yml` file.
- [Configure DAST using the UI](#configure-dast-using-the-ui).
### DAST job order ### 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) Find the latest DAST versions on the [Releases](https://gitlab.com/security-products/dast/-/releases)
page. 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 #### 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. 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)** ## Security approvals in merge requests **(ULTIMATE)**
You can require that a member of your security team approves a merge request if a 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 merge request could introduce a vulnerability.
[Security approvals in merge requests](../../../application_security/index.md#security-approvals-in-merge-requests).
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 ## 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 API
module Entities module Entities
class ProjectServiceBasic < Grape::Entity class ProjectIntegrationBasic < Grape::Entity
expose :id, :title expose :id, :title
expose :slug do |service| expose :slug do |integration|
service.to_param.dasherize integration.to_param.dasherize
end end
expose :created_at, :updated_at, :active expose :created_at, :updated_at, :active
expose :commit_events, :push_events, :issues_events, :confidential_issues_events 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
end end
TRIGGER_SERVICES = { TRIGGER_INTEGRATIONS = {
'mattermost-slash-commands' => [ 'mattermost-slash-commands' => [
{ {
name: :token, name: :token,
@ -60,24 +60,24 @@ module API
before { authorize_admin_project } before { authorize_admin_project }
helpers do helpers do
def service_attributes(service) def integration_attributes(integration)
service.fields.inject([]) do |arr, hash| integration.fields.inject([]) do |arr, hash|
arr << hash[:name].to_sym arr << hash[:name].to_sym
end end
end end
end end
desc 'Get all active project services' do desc 'Get all active project integrations' do
success Entities::ProjectServiceBasic success Entities::ProjectIntegrationBasic
end end
get ":id/services" do 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 end
INTEGRATIONS.each do |slug, settings| INTEGRATIONS.each do |slug, settings|
desc "Set #{slug} service for project" desc "Set #{slug} integration for project"
params do params do
settings.each do |setting| settings.each do |setting|
if setting[:required] if setting[:required]
@ -92,7 +92,7 @@ module API
params = declared_params(include_missing: false).merge(active: true) params = declared_params(include_missing: false).merge(active: true)
if integration.update(params) if integration.update(params)
present integration, with: Entities::ProjectService present integration, with: Entities::ProjectIntegration
else else
render_api_error!('400 Bad Request', 400) render_api_error!('400 Bad Request', 400)
end end
@ -107,14 +107,14 @@ module API
integration = user_project.find_or_initialize_integration(params[:slug].underscore) integration = user_project.find_or_initialize_integration(params[:slug].underscore)
destroy_conditionally!(integration) do 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) render_api_error!('400 Bad Request', 400) unless integration.update(attrs)
end end
end end
desc 'Get the integration settings for a project' do desc 'Get the integration settings for a project' do
success Entities::ProjectService success Entities::ProjectIntegration
end end
params do params do
requires :slug, type: String, values: INTEGRATIONS.keys, desc: 'The name of the service' 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? not_found!('Service') unless integration&.persisted?
present integration, with: Entities::ProjectService present integration, with: Entities::ProjectIntegration
end end
end end
TRIGGER_SERVICES.each do |service_slug, settings| TRIGGER_INTEGRATIONS.each do |integration_slug, settings|
helpers do helpers do
def slash_command_service(project, service_slug, params) def slash_command_integration(project, integration_slug, params)
project.integrations.active.find do |service| project.integrations.active.find do |integration|
service.try(:token) == params[:token] && service.to_param == service_slug.underscore integration.try(:token) == params[:token] && integration.to_param == integration_slug.underscore
end end
end end
end end
@ -141,7 +141,7 @@ module API
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
end end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 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' detail 'Added in GitLab 8.13'
end end
params do params do
@ -149,14 +149,14 @@ module API
requires setting[:name], type: setting[:type], desc: setting[:desc] requires setting[:name], type: setting[:type], desc: setting[:desc]
end end
end end
post ":id/services/#{service_slug.underscore}/trigger" do post ":id/services/#{integration_slug.underscore}/trigger" do
project = find_project(params[:id]) project = find_project(params[:id])
# This is not accurate, but done to prevent leakage of the project names # This is not accurate, but done to prevent leakage of the project names
not_found!('Service') unless project not_found!('Service') unless project
service = slash_command_service(project, service_slug, params) integration = slash_command_integration(project, integration_slug, params)
result = service.try(:trigger, params) result = integration.try(:trigger, params)
if result if result
status result[:status] || 200 status result[:status] || 200

View File

@ -31,6 +31,10 @@ module Gitlab
raise NotImplementedError raise NotImplementedError
end 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. # 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 # Example: get me all the Issue records between start event end end event
def timestamp_projection 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".' ERROR_MESSAGE = 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".'
def validate! 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 # 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? return unless project.lfs_enabled?

View File

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

View File

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

View File

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

View File

@ -883,12 +883,6 @@ module Gitlab
end end
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:) def squash(user, squash_id, start_sha:, end_sha:, author:, message:)
wrapped_gitaly_errors do wrapped_gitaly_errors do
gitaly_operation_client.user_squash(user, squash_id, start_sha, end_sha, author, message) gitaly_operation_client.user_squash(user, squash_id, start_sha, end_sha, author, message)

View File

@ -152,23 +152,6 @@ module Gitlab
) )
end 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) def squash_in_progress?(squash_id)
request = Gitaly::IsSquashInProgressRequest.new( request = Gitaly::IsSquashInProgressRequest.new(
repository: @gitaly_repo, 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 { ISSUABLE } from '~/boards/constants';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_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', () => { describe('BoardContentSidebar', () => {
let wrapper; let wrapper;
@ -26,7 +27,7 @@ describe('BoardContentSidebar', () => {
}, },
getters: { getters: {
activeBoardItem: () => { activeBoardItem: () => {
return { ...mockIssue, epic: null }; return { ...mockActiveIssue, epic: null };
}, },
groupPathForActiveIssue: () => mockIssueGroupPath, groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath, projectPathForActiveIssue: () => mockIssueProjectPath,
@ -110,6 +111,10 @@ describe('BoardContentSidebar', () => {
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true); expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
}); });
it('renders SidebarTodoWidget', () => {
expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => { it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
}); });
@ -147,7 +152,7 @@ describe('BoardContentSidebar', () => {
expect(toggleBoardItem).toHaveBeenCalledTimes(1); expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: { ...mockIssue, epic: null }, boardItem: { ...mockActiveIssue, epic: null },
sidebarType: ISSUABLE, sidebarType: ISSUABLE,
}); });
}); });

View File

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

View File

@ -182,6 +182,7 @@ export const mockIssue = {
export const mockActiveIssue = { export const mockActiveIssue = {
...mockIssue, ...mockIssue,
fullId: 'gid://gitlab/Issue/436',
id: 436, id: 436,
iid: '27', iid: '27',
subscribed: false, 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 DesignTodoButton from '~/design_management/components/design_todo_button.vue';
import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql'; import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql';
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.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'; import mockDesign from '../mock_data/design';
const mockDesignWithPendingTodos = { const mockDesignWithPendingTodos = {

View File

@ -66,22 +66,6 @@ describe('RightSidebar', () => {
assertSidebarState('collapsed'); 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', () => { it('should not hide collapsed icons', () => {
[].forEach.call(document.querySelectorAll('.sidebar-collapsed-icon'), (el) => { [].forEach.call(document.querySelectorAll('.sidebar-collapsed-icon'), (el) => {
expect(el.querySelector('.fa, svg').classList.contains('hidden')).toBeFalsy(); 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 { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
@ -6,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; 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'; import { todosResponse, noTodosResponse } from '../../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
@ -28,6 +29,7 @@ describe('Sidebar Todo Widget', () => {
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
provide: { provide: {
canUpdate: true, canUpdate: true,
isClassicSidebar: true,
}, },
propsData: { propsData: {
fullPath: 'group', fullPath: 'group',
@ -83,4 +85,42 @@ describe('Sidebar Todo Widget', () => {
expect(createFlash).toHaveBeenCalled(); 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 { GlButton } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils'; 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', () => { describe('Todo Button', () => {
let wrapper; let wrapper;

View File

@ -75,4 +75,12 @@ RSpec.describe Gitlab::Diff::FileCollection::Commit do
] ]
end end
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 end

View File

@ -15,7 +15,7 @@ RSpec.describe Gitlab::Diff::FileCollection::Compare do
head_commit.id) head_commit.id)
end end
it_behaves_like 'diff statistics' do let(:diffable) { Compare.new(raw_compare, project) }
let(:collection_default_args) do let(:collection_default_args) do
{ {
project: diffable.project, project: diffable.project,
@ -24,20 +24,11 @@ RSpec.describe Gitlab::Diff::FileCollection::Compare do
} }
end end
let(:diffable) { Compare.new(raw_compare, project) } it_behaves_like 'diff statistics' do
let(:stub_path) { '.gitignore' } let(:stub_path) { '.gitignore' }
end end
it_behaves_like 'sortable diff files' do 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 let(:unsorted_diff_files_paths) do
[ [
'.DS_Store', '.DS_Store',
@ -66,4 +57,12 @@ RSpec.describe Gitlab::Diff::FileCollection::Compare do
] ]
end end
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 end

View File

@ -25,4 +25,12 @@ RSpec.describe Gitlab::Diff::FileCollection::MergeRequestDiffBase do
end end
end 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 end

View File

@ -209,19 +209,6 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
end end
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 describe '#squash_in_progress?' do
let(:squash_id) { 1 } let(:squash_id) { 1 }

View File

@ -126,4 +126,13 @@ RSpec.describe DiffDiscussion do
end end
end 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 end

View File

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

View File

@ -25,30 +25,6 @@ RSpec.describe MergeRequests::RebaseService do
end end
describe '#execute' do 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 shared_examples 'sequence of failure and success' do
it 'properly clears the error message' do it 'properly clears the error message' do
allow(repository).to receive(:gitaly_operation_client).and_raise('Something went wrong') 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_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 context 'when skip_ci flag is set' do
let(:skip_ci) { true } let(:skip_ci) { true }

View File

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

View File

@ -3,6 +3,7 @@
RSpec.shared_examples_for 'value stream analytics event' do RSpec.shared_examples_for 'value stream analytics event' do
let(:params) { {} } let(:params) { {} }
let(:instance) { described_class.new(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.name).to be_a_kind_of(String) }
it { expect(described_class.identifier).to be_a_kind_of(Symbol) } 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) expect(output_query).to be_a_kind_of(ActiveRecord::Relation)
end end
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 end

View File

@ -122,6 +122,22 @@ RSpec.shared_examples 'value stream analytics stage' do
expect(stage.parent_id).to eq(parent.id) expect(stage.parent_id).to eq(parent.id)
end end
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 end
RSpec.shared_examples 'value stream analytics label based stage' do RSpec.shared_examples 'value stream analytics label based stage' do