Commit 7797cff0 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 0e78cc64 58ea5d5d
<script>
import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { truncate } from '~/lib/utils/text_utility';
import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants';
......@@ -8,7 +8,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
GlAvatarLink,
GlAvatarLabeled,
GlBadge,
GlIcon,
......@@ -27,6 +26,11 @@ export default {
adminUserHref() {
return this.adminUserPath.replace('id', this.user.username);
},
adminUserMailto() {
// NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
// eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.user.email}`;
},
userNoteShort() {
return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP);
},
......@@ -36,10 +40,9 @@ export default {
</script>
<template>
<gl-avatar-link
<div
v-if="user"
class="js-user-link"
:href="adminUserHref"
class="js-user-link gl-display-inline-block"
:data-user-id="user.id"
:data-username="user.username"
>
......@@ -48,6 +51,8 @@ export default {
:src="user.avatarUrl"
:label="user.name"
:sub-label="user.email"
:label-link="adminUserHref"
:sub-label-link="adminUserMailto"
>
<template #meta>
<div v-if="user.note" class="gl-text-gray-500 gl-p-1">
......@@ -60,5 +65,5 @@ export default {
</div>
</template>
</gl-avatar-labeled>
</gl-avatar-link>
</div>
</template>
......@@ -13,8 +13,8 @@ export default {
</script>
<template>
<span class="gl-ml-4">
<gl-button variant="success" @click="setAddColumnFormVisibility(true)"
<span class="gl-ml-3 gl-display-flex gl-align-items-center">
<gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }}
</gl-button>
</span>
......
......@@ -10,7 +10,6 @@ import {
} from '@gitlab/ui';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import { BV_DROPDOWN_HIDE } from '~/lib/utils/constants';
import { __, s__ } from '~/locale';
import projectMilestones from '../../graphql/project_milestones.query.graphql';
......@@ -73,21 +72,20 @@ export default {
return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone;
},
},
mounted() {
this.$root.$on(BV_DROPDOWN_HIDE, () => {
this.$refs.sidebarItem.collapse();
});
},
methods: {
...mapActions(['setActiveIssueMilestone']),
handleOpen() {
this.edit = true;
this.$refs.dropdown.show();
},
handleClose() {
this.edit = false;
this.$refs.sidebarItem.collapse();
},
async setMilestone(milestoneId) {
this.loading = true;
this.searchTitle = '';
this.$refs.sidebarItem.collapse();
this.handleClose();
try {
const input = { milestoneId, projectPath: this.projectPath };
......@@ -116,7 +114,7 @@ export default {
:title="$options.i18n.milestone"
:loading="loading"
@open="handleOpen()"
@close="edit = false"
@close="handleClose"
>
<template v-if="hasMilestone" #collapsed>
<strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong>
......@@ -126,6 +124,7 @@ export default {
:text="dropdownText"
:header-text="$options.i18n.assignMilestone"
block
@hide="handleClose"
>
<gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" />
<gl-dropdown-item
......
<script>
import { GlIcon } from '@gitlab/ui';
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import { hide } from '~/tooltips';
export default {
components: {
GlIcon,
GlButton,
},
directives: {
GlTooltip,
},
props: {
issueBoardsContentSelector: {
......@@ -35,18 +38,15 @@ export default {
</script>
<template>
<div class="board-extra-actions">
<a
<div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center">
<gl-button
ref="toggleFocusModeButton"
href="#"
class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn"
v-gl-tooltip
:icon="isFullscreen ? 'minimize' : 'maximize'"
class="js-focus-mode-btn"
data-qa-selector="focus_mode_button"
role="button"
:aria-label="$options.i18n.toggleFocusMode"
:title="$options.i18n.toggleFocusMode"
@click="toggleFocusMode"
>
<gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" />
</a>
/>
</div>
</template>
......@@ -2,8 +2,7 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import { hide, dispose } from '~/tooltips';
import { dispose } from '~/tooltips';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
......@@ -30,7 +29,6 @@ export default class LabelManager {
}
bindEvents() {
this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
}
......@@ -46,11 +44,6 @@ export default class LabelManager {
_this.toggleEmptyState($label, $btn, action);
}
onButtonActionClick(e) {
e.stopPropagation();
hide(e.currentTarget);
}
toggleEmptyState() {
this.emptyState.classList.toggle(
'hidden',
......
......@@ -12,25 +12,23 @@ import initSearchSettings from '~/search_settings';
import initProjectPermissionsSettings from '../shared/permissions';
import initProjectLoadingSpinner from '../shared/save_project_loader';
document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
initConfirmDangerModal();
initSettingsPanels();
initProjectDeleteButton();
mountBadgeSettings(PROJECT_BADGE);
initFilePickers();
initConfirmDangerModal();
initSettingsPanels();
initProjectDeleteButton();
mountBadgeSettings(PROJECT_BADGE);
new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
initServiceDesk();
new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
initServiceDesk();
initProjectLoadingSpinner();
initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2');
initProjectLoadingSpinner();
initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2');
dirtySubmitFactory(
document.querySelectorAll(
'.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
),
);
dirtySubmitFactory(
document.querySelectorAll(
'.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
),
);
initSearchSettings();
});
initSearchSettings();
import initEnvironments from '~/environments/';
document.addEventListener('DOMContentLoaded', initEnvironments);
initEnvironments();
......@@ -280,7 +280,7 @@ module ApplicationHelper
def page_class
class_names = []
class_names << 'issue-boards-page' if current_controller?(:boards)
class_names << 'issue-boards-page gl-overflow-hidden' if current_controller?(:boards)
class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class
......
......@@ -228,17 +228,6 @@ module DesignManagement
project
end
def immediately_before?(next_design)
return false if next_design.relative_position <= relative_position
interloper = self.class.on_issue(issue).where(
"relative_position <@ int4range(?, ?, '()')",
*[self, next_design].map(&:relative_position)
)
!interloper.exists?
end
def notes_with_associations
notes.includes(:author)
end
......
......@@ -16,7 +16,6 @@ module DesignManagement
return error(:cannot_move) unless current_user.can?(:move_design, current_design)
return error(:no_neighbors) unless neighbors.present?
return error(:not_distinct) unless all_distinct?
return error(:not_adjacent) if any_in_gap?
return error(:not_same_issue) unless all_same_issue?
move_nulls_to_end
......@@ -54,12 +53,6 @@ module DesignManagement
ids.uniq.size == ids.size
end
def any_in_gap?
return false unless previous_design&.relative_position && next_design&.relative_position
!previous_design.immediately_before?(next_design)
end
def all_same_issue?
issue.designs.id_in(ids).count == ids.size
end
......
......@@ -101,8 +101,30 @@ module MergeRequests
%w(title description).each do |action|
next unless @issuable_changes.key?(action)
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
# Track edits to title or description
#
merge_request_activity_counter
.public_send("track_#{action}_edit_action".to_sym, user: current_user) # rubocop:disable GitlabSecurity/PublicSend
# Track changes to Draft/WIP status
#
if action == "title"
old_title, new_title = @issuable_changes["title"]
old_title_wip = MergeRequest.work_in_progress?(old_title)
new_title_wip = MergeRequest.work_in_progress?(new_title)
if !old_title_wip && new_title_wip
# Marked as Draft/WIP
#
merge_request_activity_counter
.track_marked_as_draft_action(user: current_user)
elsif old_title_wip && !new_title_wip
# Unmarked as Draft/WIP
#
merge_request_activity_counter
.track_unmarked_as_draft_action(user: current_user)
end
end
end
end
......
......@@ -29,7 +29,7 @@
%ul
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
%button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button',
%button.js-promote-project-label-button.btn.btn-transparent{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label),
label_title: label.title,
label_color: label.color,
......
.dropdown.gl-ml-3#js-add-list
%button.gl-button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
.dropdown.gl-display-flex.gl-align-items-center.gl-ml-3#js-add-list
%button.gl-button.btn.btn-confirm.btn-confirm-secondary.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
......
......@@ -193,6 +193,8 @@
.filter-dropdown-container.d-flex.flex-column.flex-md-row
- if type == :boards
#js-board-labels-toggle
- if current_user
#js-board-epics-swimlanes-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list
- if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml)
......@@ -200,9 +202,7 @@
- else
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- if current_user
#js-board-epics-swimlanes-toggle
#js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
......@@ -8,7 +8,7 @@
= markdown_field(label, :description)
.float-right.d-none.d-lg-block
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary btn-action' do
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue')
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary btn-action' do
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue')
---
title: Allow running Puma in Single mode
merge_request: 53830
author:
type: other
---
title: Fix spurious not-adjacent error when moving designs
merge_request: 53771
author:
type: fixed
---
title: Align and reorder boards search bar buttons
merge_request: 53690
author:
type: changed
---
name: usage_data_i_code_review_user_marked_as_draft
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301223
rollout_issue_url:
milestone: '13.9'
type: development
group: group::code review
default_enabled: true
---
name: usage_data_i_code_review_user_unmarked_as_draft
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301223
rollout_issue_url:
milestone: '13.9'
type: development
group: group::code review
default_enabled: true
# frozen_string_literal: true
def allow_single_mode?
return false if Gitlab.com?
Gitlab::Utils.to_boolean(ENV['PUMA_SKIP_CLUSTER_VALIDATION'])
def max_puma_workers
Puma.cli_config.options[:workers].to_i
end
if Gitlab::Runtime.puma? && ::Puma.cli_config.options[:workers].to_i == 0
return if allow_single_mode?
if Gitlab::Runtime.puma? && max_puma_workers == 0
raise 'Puma is only supported in Clustered mode (workers > 0)' if Gitlab.com?
raise 'Puma is only supported in Cluster-mode: workers > 0'
warn 'WARNING: Puma is running in Single mode (workers = 0). Some features may not work. Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5303 for info.'
end
......@@ -5902,6 +5902,46 @@ Identifier of Dast::Profile.
"""
scalar DastProfileID
"""
Autogenerated input type of DastProfileRun
"""
input DastProfileRunInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Full path for the project the scanner profile belongs to.
"""
fullPath: ID!
"""
ID of the profile to be used for the scan.
"""
id: DastProfileID!
}
"""
Autogenerated return type of DastProfileRun
"""
type DastProfileRunPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
URL of the pipeline that was created.
"""
pipelineUrl: String
}
enum DastScanTypeEnum {
"""
Active DAST scan. This scan will make active attacks against the target site.
......@@ -16399,6 +16439,7 @@ type Mutation {
dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload
dastProfileCreate(input: DastProfileCreateInput!): DastProfileCreatePayload
dastProfileDelete(input: DastProfileDeleteInput!): DastProfileDeletePayload
dastProfileRun(input: DastProfileRunInput!): DastProfileRunPayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload
dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload
......
......@@ -16059,6 +16059,122 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastProfileRunInput",
"description": "Autogenerated input type of DastProfileRun",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "Full path for the project the scanner profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "id",
"description": "ID of the profile to be used for the scan.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastProfileRunPayload",
"description": "Autogenerated return type of DastProfileRun",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineUrl",
"description": "URL of the pipeline that was created.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastScanTypeEnum",
......@@ -45855,6 +45971,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastProfileRun",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastProfileRunInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastProfileRunPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastScannerProfileCreate",
"description": null,
......@@ -952,6 +952,16 @@ Autogenerated return type of DastProfileDelete.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### DastProfileRunPayload
Autogenerated return type of DastProfileRun.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
### DastScannerProfile
Represents a DAST scanner profile.
......
......@@ -49,7 +49,7 @@ export default {
</script>
<template>
<div class="gl-ml-3">
<div class="gl-ml-3 gl-display-flex gl-align-items-center">
<gl-button
v-gl-modal-directive="'board-config-modal'"
v-gl-tooltip
......
......@@ -64,15 +64,25 @@ export default {
},
methods: {
...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']),
openEpicsDropdown() {
if (!this.loading) {
this.$refs.epicSelect.handleEditClick();
handleOpen() {
if (!this.epicFetchInProgress) {
this.$refs.epicSelect.toggleFormDropdown();
} else {
this.$refs.sidebarItem.collapse();
}
},
async setEpic(selectedEpic) {
handleClose() {
this.$refs.sidebarItem.collapse();
this.$refs.epicSelect.toggleFormDropdown();
},
async setEpic(selectedEpic) {
this.handleClose();
const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null;
const assignedEpicId = this.epic?.id ? fullEpicId(this.epic.id) : null;
if (epicId === assignedEpicId) {
return;
}
try {
await this.setActiveIssueEpic(epicId);
......@@ -89,7 +99,8 @@ export default {
ref="sidebarItem"
:title="$options.i18n.epic"
:loading="epicFetchInProgress"
@open="openEpicsDropdown"
@open="handleOpen"
@close="handleClose"
>
<template v-if="epicData.title" #collapsed>
<a class="gl-text-gray-900! gl-font-weight-bold" href="#">
......@@ -107,6 +118,7 @@ export default {
variant="standalone"
:show-header="false"
@epicSelect="setEpic"
@hide="handleClose"
/>
</board-editable-item>
</template>
......@@ -54,16 +54,12 @@ export default {
data-testid="toggle-swimlanes"
>
<span
class="board-swimlanes-toggle-text gl-white-space-nowrap gl-font-weight-bold"
class="board-swimlanes-toggle-text gl-white-space-nowrap gl-font-weight-bold gl-line-height-normal"
data-testid="toggle-swimlanes-label"
>
{{ __('Group by') }}
</span>
<gl-dropdown
right
:text="dropdownLabel"
toggle-class="gl-ml-3 gl-border-none gl-inset-border-1-gray-200! border-radius-default"
>
<gl-dropdown right :text="dropdownLabel" class="gl-ml-3" toggle-class="gl-line-height-normal!">
<gl-dropdown-item
:is-check-item="true"
:is-checked="!isShowingEpicsSwimlanes"
......
......@@ -20,6 +20,8 @@ import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site
import { initFormField } from 'ee/security_configuration/utils';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { serializeFormObject } from '~/lib/utils/forms';
import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
......@@ -42,6 +44,8 @@ import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from './profile_selector/site_profile_selector.vue';
export const ON_DEMAND_SCANS_STORAGE_KEY = 'on-demand-scans-new-form';
const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ({
query: fetchQuery,
variables() {
......@@ -80,6 +84,7 @@ export default {
GlLink,
GlSkeletonLoader,
GlSprintf,
LocalStorageSync,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -157,6 +162,7 @@ export default {
errorType: null,
errors: [],
showAlert: false,
clearStorage: false,
};
},
computed: {
......@@ -229,6 +235,14 @@ export default {
} = this;
return isFormInvalid || (loading && loading !== saveScanBtnId);
},
formFieldValues() {
const { selectedScannerProfileId, selectedSiteProfileId } = this;
return {
...serializeFormObject(this.form.fields),
selectedScannerProfileId,
selectedSiteProfileId,
};
},
},
created() {
const params = queryToObject(window.location.search);
......@@ -264,8 +278,7 @@ export default {
input = {
...input,
...(this.isEdit ? { id: this.dastScan.id } : {}),
name: this.form.fields.name.value,
description: this.form.fields.description.value,
...serializeFormObject(this.form.fields),
runAfterCreate,
};
}
......@@ -285,7 +298,9 @@ export default {
this.loading = false;
} else if (this.glFeatures.dastSavedScans && !runAfterCreate) {
redirectTo(response.dastProfile.editPath);
this.clearStorage = true;
} else {
this.clearStorage = true;
redirectTo(response.pipelineUrl);
}
})
......@@ -305,12 +320,31 @@ export default {
this.errors = [];
this.showAlert = false;
},
updateFromStorage(val) {
const { selectedSiteProfileId, selectedScannerProfileId, name, description } = val;
this.form.fields.name.value = name ?? this.form.fields.name.value;
this.form.fields.description.value = description ?? this.form.fields.description.value;
// precedence is given to profile IDs passed from the query params
this.selectedSiteProfileId = this.selectedSiteProfileId ?? selectedSiteProfileId;
this.selectedScannerProfileId = this.selectedScannerProfileId ?? selectedScannerProfileId;
},
},
ON_DEMAND_SCANS_STORAGE_KEY,
};
</script>
<template>
<gl-form novalidate @submit.prevent="onSubmit()">
<local-storage-sync
v-if="glFeatures.dastSavedScans && !isEdit"
as-json
:storage-key="$options.ON_DEMAND_SCANS_STORAGE_KEY"
:clear="clearStorage"
:value="formFieldValues"
@input="updateFromStorage"
/>
<header class="gl-mb-6">
<div class="gl-mt-6 gl-display-flex">
<h2 class="gl-flex-grow-1 gl-my-0">{{ title }}</h2>
......
......@@ -199,6 +199,7 @@ export default {
},
hideDropdown() {
this.isDropdownShowing = this.isDropdownVariantStandalone;
this.$emit('hide');
},
toggleFormDropdown() {
const { dropdown } = this.$refs.dropdown.$refs;
......
......@@ -44,6 +44,7 @@ module EE
mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::Dast::Profiles::Create
mount_mutation ::Mutations::Dast::Profiles::Delete
mount_mutation ::Mutations::Dast::Profiles::Run
mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Update
mount_mutation ::Mutations::DastSiteProfiles::Delete
......
# frozen_string_literal: true
module Mutations
module Dast
module Profiles
class Run < BaseMutation
include FindsProject
graphql_name 'DastProfileRun'
ProfileID = ::Types::GlobalIDType[::Dast::Profile]
field :pipeline_url, GraphQL::STRING_TYPE,
null: true,
description: 'URL of the pipeline that was created.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path for the project the scanner profile belongs to.'
argument :id, ProfileID,
required: true,
description: 'ID of the profile to be used for the scan.'
authorize :create_on_demand_dast_scan
def resolve(full_path:, id:)
project = authorized_find!(full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
# TODO: remove this line once the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ProfileID.coerce_isolated_input(id).model_id
dast_profile = find_dast_profile(project, id)
return { errors: ['Profile not found for given parameters'] } unless dast_profile
response = create_on_demand_dast_scan(project, dast_profile)
return { errors: response.errors } if response.error?
{ errors: [], pipeline_url: response.payload.fetch(:pipeline_url) }
end
private
def allowed?(project)
project.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:dast_saved_scans, project, default_enabled: :yaml)
end
def find_dast_profile(project, id)
::Dast::ProfilesFinder.new(project_id: project.id, id: id)
.execute
.first
end
def create_on_demand_dast_scan(project, dast_profile)
::DastOnDemandScans::CreateService.new(
container: project,
current_user: current_user,
params: {
dast_site_profile: dast_profile.dast_site_profile,
dast_scanner_profile: dast_profile.dast_scanner_profile
}
).execute
end
end
end
end
end
......@@ -2,8 +2,8 @@
- milestone_lists_available = board.resource_parent.feature_available?(:board_milestone_lists)
- if assignee_lists_available || milestone_lists_available
.dropdown.boards-add-list.gl-ml-3#js-add-list
%button.btn.gl-button.btn-success.btn-inverted.d-flex.js-new-board-list{ type: "button", data: board_list_data }
.dropdown.boards-add-list.gl-ml-3.gl-display-flex.gl-align-items-center#js-add-list
%button.btn.gl-button.btn-confirm.btn-confirm-secondary.gl-display-flex.js-new-board-list{ type: "button", data: board_list_data }
%span Add list
= sprite_icon('chevron-down', css_class: 'gl-ml-2 btn-success-board-list-chevron')
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.dropdown-menu-tabs.pt-0
......
......@@ -27,7 +27,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
wrapper = null;
});
const fakeStore = ({
const createStore = ({
initialState = {
activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
......@@ -59,7 +59,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
BoardEditableItem,
EpicsSelect: stubComponent(EpicsSelect, {
methods: {
handleEditClick: epicsSelectHandleEditClick,
toggleFormDropdown: epicsSelectHandleEditClick,
},
}),
},
......@@ -69,9 +69,43 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' });
const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' });
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
describe('when not editing', () => {
it('expands the milestone dropdown on clicking edit', async () => {
createStore();
createWrapper();
await findBoardEditableItem().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
});
describe('when editing', () => {
beforeEach(() => {
createStore();
createWrapper();
findItemWrapper().vm.$emit('open');
jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse');
});
it('collapses BoardEditableItem on clicking edit', async () => {
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
await wrapper.find(EpicsSelect).vm.$emit('hide');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
});
it('renders "None" when no epic is assigned to the active issue', async () => {
fakeStore();
createStore();
createWrapper();
await wrapper.vm.$nextTick();
......@@ -83,7 +117,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
it('fetches an epic for active issue', () => {
const fetchEpicForActiveIssue = jest.fn(() => Promise.resolve());
fakeStore({
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
......@@ -101,7 +135,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
});
it('flashes an error message when fetch fails', async () => {
fakeStore({
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
......@@ -126,7 +160,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
});
it('renders epic title when issue has an assigned epic', async () => {
fakeStore({
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
......@@ -143,18 +177,9 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
});
});
it('expands the dropdown when editing', () => {
fakeStore();
createWrapper();
findItemWrapper().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
describe('when epic is selected', () => {
beforeEach(async () => {
fakeStore({
createStore({
initialState: {
activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
......@@ -190,11 +215,25 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(mockAssignedEpic.title);
});
describe('when the selected epic did not change', () => {
it('does not commit change to the server', async () => {
createStore();
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation();
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveIssueEpic).not.toHaveBeenCalled();
});
});
});
describe('when no epic is selected', () => {
beforeEach(async () => {
fakeStore({
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
......@@ -226,7 +265,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
});
it('flashes an error when update fails', async () => {
fakeStore({
createStore({
actionsMock: {
setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'),
},
......@@ -234,7 +273,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
createWrapper();
findEpicSelect().vm.$emit('epicSelect', null);
findEpicSelect().vm.$emit('epicSelect', { id: 'foo' });
await wrapper.vm.$nextTick();
......
......@@ -13,6 +13,8 @@ import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/gr
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import { stubComponent } from 'helpers/stub_component';
import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import * as responses from '../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../mocks/mock_data';
......@@ -43,6 +45,7 @@ const dastScan = {
siteProfileId: validatedSiteProfile.id,
};
useLocalStorageSpy();
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
queryToObject: jest.requireActual('~/lib/utils/url_utility').queryToObject,
......@@ -50,6 +53,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
const LOCAL_STORAGE_KEY = 'on-demand-scans-new-form';
describe('OnDemandScansForm', () => {
let localVue;
let subject;
......@@ -143,6 +148,7 @@ describe('OnDemandScansForm', () => {
},
stubs: {
GlFormInput: GlFormInputStub,
LocalStorageSync,
},
},
{ ...options, localVue, apolloProvider },
......@@ -164,6 +170,7 @@ describe('OnDemandScansForm', () => {
afterEach(() => {
subject.destroy();
subject = null;
localStorage.clear();
});
it('renders properly', () => {
......@@ -216,6 +223,45 @@ describe('OnDemandScansForm', () => {
});
});
describe('local storage', () => {
it('get updated when form is modified', async () => {
mountShallowSubject();
await setValidFormData();
expect(localStorage.setItem.mock.calls).toEqual([
[
LOCAL_STORAGE_KEY,
JSON.stringify({
name: 'My daily scan',
selectedScannerProfileId: 'gid://gitlab/DastScannerProfile/1',
selectedSiteProfileId: 'gid://gitlab/DastSiteProfile/1',
}),
],
]);
});
it('reload the form data when available', async () => {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
name: dastScan.name,
description: dastScan.description,
selectedScannerProfileId: dastScan.scannerProfileId,
selectedSiteProfileId: dastScan.siteProfileId,
}),
);
mountShallowSubject();
await subject.vm.$nextTick();
expect(findNameInput().attributes('value')).toBe(dastScan.name);
expect(findDescriptionInput().attributes('value')).toBe(dastScan.description);
expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId);
expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId);
});
});
describe('submit button', () => {
let submitButton;
......@@ -271,7 +317,6 @@ describe('OnDemandScansForm', () => {
variables: {
input: {
name: 'My daily scan',
description: '',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
......@@ -288,6 +333,10 @@ describe('OnDemandScansForm', () => {
it('does not show an alert', async () => {
expect(findAlert().exists()).toBe(false);
});
it('clears local storage', () => {
expect(localStorage.removeItem.mock.calls).toEqual([[LOCAL_STORAGE_KEY]]);
});
});
describe('when editing an existing scan', () => {
......@@ -539,5 +588,25 @@ describe('OnDemandScansForm', () => {
expect(subject.find(SiteProfileSelector).attributes('value')).toBe(siteProfile.id);
expect(subject.find(ScannerProfileSelector).attributes('value')).toBe(scannerProfile.id);
});
it('when local storage data is available', async () => {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
selectedScannerProfileId: dastScan.scannerProfileId,
selectedSiteProfileId: dastScan.siteProfileId,
}),
);
global.jsdom.reconfigure({
url: setUrlParams({ site_profile_id: 1, scanner_profile_id: 1 }, URL_HOST),
});
mountShallowSubject();
await subject.vm.$nextTick();
expect(findScannerProfilesSelector().attributes('value')).toBe(scannerProfile.id);
expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id);
});
});
});
......@@ -137,6 +137,12 @@ describe('EpicsSelect', () => {
expect(wrapperStandalone.vm.isDropdownShowing).toBe(true);
});
it('should emit `hide` event', () => {
wrapperStandalone.vm.hideDropdown();
expect(wrapperStandalone.emitted().hide.length).toBe(1);
});
});
describe('handleItemSelect', () => {
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Dast::Profiles::Run do
let_it_be_with_refind(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let(:full_path) { project.full_path }
let(:dast_profile_id) { dast_profile.to_global_id }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
specify { expect(described_class).to require_graphql_authorizations(:create_on_demand_dast_scan) }
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
id: dast_profile_id
)
end
context 'when the feature flag dast_saved_scans is disabled' do
it 'raises an exception' do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when on demand scan licensed feature is not available' do
it 'raises an exception' do
stub_licensed_features(security_on_demand_scans: false)
stub_feature_flags(dast_saved_scans: true)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the feature is enabled' do
before do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: true)
end
context 'when the project does not exist' do
let(:full_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns a pipeline_url containing the correct path' do
actual_url = subject[:pipeline_url]
pipeline = Ci::Pipeline.last
expected_url = Gitlab::Routing.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(actual_url).to eq(expected_url)
end
context 'when the dast_profile does not exist' do
let(:dast_profile_id) { Gitlab::GlobalId.build(nil, model_name: 'Dast::Profile', id: 'does_not_exist') }
it 'communicates failure' do
expect(subject[:errors]).to include('Profile not found for given parameters')
end
end
context 'when scan_type=active' do
let(:dast_scanner_profile) { create(:dast_scanner_profile, project: project, scan_type: 'active') }
let(:dast_profile) { create(:dast_profile, project: project, dast_scanner_profile: dast_scanner_profile) }
context 'when target is not validated' do
it 'communicates failure' do
expect(subject[:errors]).to include('Cannot run active scan against unvalidated target')
end
end
context 'when target is validated' do
it 'has no errors' do
create(:dast_site_validation, state: :passed, dast_site_token: create(:dast_site_token, project: project, url: dast_profile.dast_site_profile.dast_site.url))
expect(subject[:errors]).to be_empty
end
end
end
end
end
end
end
......@@ -76,7 +76,8 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul
end
def finding_params(primary_identifier_id, project_id)
attrs = attributes_for(:vulnerabilities_finding) # rubocop: disable RSpec/FactoriesInMigrationSpecs
uuid = SecureRandom.uuid
{
severity: 0,
confidence: 5,
......@@ -84,23 +85,112 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul
project_id: project_id,
scanner_id: 6,
primary_identifier_id: primary_identifier_id,
project_fingerprint: attrs[:project_fingerprint],
project_fingerprint: SecureRandom.hex(20),
location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)),
uuid: SecureRandom.uuid,
name: attrs[:name],
uuid: uuid,
name: "Vulnerability Finding #{uuid}",
metadata_version: '1.3',
raw_metadata: attrs[:raw_metadata]
raw_metadata: raw_metadata
}
end
def create_identifier(number_of)
(1..number_of).each do |identifier_id|
identifiers.create!(id: identifier_id,
project_id: 123,
fingerprint: 'd432c2ad2953e8bd587a3a43b3ce309b5b0154c' + identifier_id.to_s,
external_type: 'SECURITY_ID',
external_id: 'SECURITY_0',
name: 'SECURITY_IDENTIFIER 0')
end
def raw_metadata
{
"description" => "The cipher does not provide data integrity update 1",
"message" => "The cipher does not provide data integrity",
"cve" => "818bf5dacb291e15d9e6dc3c5ac32178:CIPHER",
"solution" => "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.",
"location" => {
"file" => "maven/src/main/java/com/gitlab/security_products/tests/App.java",
"start_line" => 29,
"end_line" => 29,
"class" => "com.gitlab.security_products.tests.App",
"method" => "insecureCypher"
},
"links" => [
{
"name" => "Cipher does not check for integrity first?",
"url" => "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
}
],
"assets" => [
{
"type" => "postman",
"name" => "Test Postman Collection",
"url" => "http://localhost/test.collection"
}
],
"evidence" => {
"summary" => "Credit card detected",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => nil,
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
},
"response" => {
"reason_phrase" => "OK",
"status_code" => 200,
"body" => nil,
"headers" => [
{
"name" => "Content-Length",
"value" => "0"
}
]
},
"source" => {
"id" => "assert:Response Body Analysis",
"name" => "Response Body Analysis",
"url" => "htpp://hostname/documentation"
},
"supporting_messages" => [
{
"name" => "Origional",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => "",
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
}
},
{
"name" => "Recorded",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => "",
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
},
"response" => {
"reason_phrase" => "OK",
"status_code" => 200,
"body" => "",
"headers" => [
{
"name" => "Content-Length",
"value" => "0"
}
]
}
}
]
}
}
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Running a DAST Profile' do
include GraphqlHelpers
let!(:dast_profile) { create(:dast_profile, project: project) }
let(:mutation_name) { :dast_profile_run }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: project.full_path,
id: global_id_of(dast_profile)
)
end
it_behaves_like 'an on-demand scan mutation when user cannot run an on-demand scan'
it_behaves_like 'an on-demand scan mutation when user can run an on-demand scan' do
it 'returns a pipeline_url containing the correct path' do
post_graphql_mutation(mutation, current_user: current_user)
pipeline = Ci::Pipeline.last
expected_url = Gitlab::Routing.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(mutation_response['pipelineUrl']).to eq(expected_url)
end
context 'when pipeline creation fails' do
before do
allow_next_instance_of(Ci::Pipeline) do |instance|
allow(instance).to receive(:created_successfully?).and_return(false)
allow(instance).to receive(:full_error_messages).and_return('error message')
end
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['error message']
end
end
end
# See Usage Ping metrics dictionary docs https://docs.gitlab.com/ee/development/usage_ping/metrics_dictionary.html
key_path: <%= key_path %>
value_type:
description:
product_section:
product_stage:
product_group:
product_category:
stage:
status:
value_type: <%= value_type %>
status: implemented
milestone:
introduced_by_url:
group:
time_frame: <%= time_frame %>
data_source:
distribution: <%= distribution %>
# tier: ['free', 'starter', 'premium', 'ultimate', 'bronze', 'silver', 'gold']
# tier: ['free', 'premium', 'ultimate']
tier:
......@@ -4,18 +4,18 @@ require 'rails/generators'
module Gitlab
class UsageMetricDefinitionGenerator < Rails::Generators::Base
Directory = Struct.new(:name, :time_frame) do
Directory = Struct.new(:name, :time_frame, :value_type) do
def match?(str)
(name == str || time_frame == str) && str != 'none'
end
end
TIME_FRAME_DIRS = [
Directory.new('counts_7d', '7d'),
Directory.new('counts_28d', '28d'),
Directory.new('counts_all', 'all'),
Directory.new('settings', 'none'),
Directory.new('license', 'none')
Directory.new('counts_7d', '7d', 'number'),
Directory.new('counts_28d', '28d', 'number'),
Directory.new('counts_all', 'all', 'number'),
Directory.new('settings', 'none', 'boolean'),
Directory.new('license', 'none', 'string')
].freeze
VALID_INPUT_DIRS = (TIME_FRAME_DIRS.flat_map { |d| [d.name, d.time_frame] } - %w(none)).freeze
......@@ -40,6 +40,10 @@ module Gitlab
directory&.time_frame
end
def value_type
directory&.value_type
end
def distribution
value = ['ce']
value << 'ee' if ee?
......
......@@ -119,6 +119,16 @@
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_assigned
- name: i_code_review_user_marked_as_draft
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_marked_as_draft
- name: i_code_review_user_unmarked_as_draft
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_unmarked_as_draft
- name: i_code_review_user_review_requested
redis_slot: code_review
category: code_review
......
......@@ -22,6 +22,8 @@ module Gitlab
MR_REMOVE_MULTILINE_COMMENT_ACTION = 'i_code_review_user_remove_multiline_mr_comment'
MR_ADD_SUGGESTION_ACTION = 'i_code_review_user_add_suggestion'
MR_APPLY_SUGGESTION_ACTION = 'i_code_review_user_apply_suggestion'
MR_MARKED_AS_DRAFT_ACTION = 'i_code_review_user_marked_as_draft'
MR_UNMARKED_AS_DRAFT_ACTION = 'i_code_review_user_unmarked_as_draft'
MR_RESOLVE_THREAD_ACTION = 'i_code_review_user_resolve_thread'
MR_UNRESOLVE_THREAD_ACTION = 'i_code_review_user_unresolve_thread'
MR_ASSIGNED_USERS_ACTION = 'i_code_review_user_assigned'
......@@ -101,6 +103,14 @@ module Gitlab
track_unique_action_by_user(MR_ADD_SUGGESTION_ACTION, user)
end
def track_marked_as_draft_action(user:)
track_unique_action_by_user(MR_MARKED_AS_DRAFT_ACTION, user)
end
def track_unmarked_as_draft_action(user:)
track_unique_action_by_user(MR_UNMARKED_AS_DRAFT_ACTION, user)
end
def track_apply_suggestion_action(user:)
track_unique_action_by_user(MR_APPLY_SUGGESTION_ACTION, user)
end
......
......@@ -21,6 +21,14 @@ FactoryBot.define do
merge_status { "can_be_merged" }
trait :draft_merge_request do
title { generate(:draft_title) }
end
trait :wip_merge_request do
title { generate(:wip_title) }
end
trait :jira_title do
title { generate(:jira_title) }
end
......
......@@ -15,6 +15,8 @@ FactoryBot.define do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") }
sequence(:variable) { |n| "var#{n}" }
sequence(:draft_title) { |n| "Draft: #{n}" }
sequence(:wip_title) { |n| "WIP: #{n}" }
sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" }
sequence(:jira_branch) { |n| "feature/PROJ-#{n}" }
end
......@@ -575,7 +575,7 @@ RSpec.describe 'Issue Boards', :js do
end
it 'shows the button' do
expect(page).to have_link('Toggle focus mode')
expect(page).to have_button('Toggle focus mode')
end
end
......
import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
......@@ -14,7 +14,7 @@ describe('AdminUserAvatar component', () => {
const findNote = () => wrapper.find(GlIcon);
const findAvatar = () => wrapper.find(GlAvatarLabeled);
const findAvatarLink = () => wrapper.find(GlAvatarLink);
const findUserLink = () => wrapper.find('.js-user-link');
const findAllBadges = () => wrapper.findAll(GlBadge);
const findTooltip = () => getBinding(findNote().element, 'gl-tooltip');
......@@ -44,20 +44,25 @@ describe('AdminUserAvatar component', () => {
initComponent();
});
it("links to the user's admin path", () => {
expect(findAvatarLink().attributes()).toMatchObject({
href: adminUserPath.replace('id', user.username),
it('adds a user link hover card', () => {
expect(findUserLink().attributes()).toMatchObject({
'data-user-id': user.id.toString(),
'data-username': user.username,
});
});
it("renders the user's name", () => {
expect(findAvatar().props('label')).toBe(user.name);
it("renders the user's name with an admin path link", () => {
const avatar = findAvatar();
expect(avatar.props('label')).toBe(user.name);
expect(avatar.props('labelLink')).toBe(adminUserPath.replace('id', user.username));
});
it("renders the user's email", () => {
expect(findAvatar().props('subLabel')).toBe(user.email);
it("renders the user's email with a mailto link", () => {
const avatar = findAvatar();
expect(avatar.props('subLabel')).toBe(user.email);
expect(avatar.props('subLabelLink')).toBe(`mailto:${user.email}`);
});
it("renders the user's avatar image", () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlDropdown } from '@gitlab/ui';
import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
......@@ -46,10 +46,42 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () =>
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findLoader = () => wrapper.find(GlLoadingIcon);
const findDropdown = () => wrapper.find(GlDropdown);
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]');
const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]');
const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]');
describe('when not editing', () => {
it('opens the milestone dropdown on clicking edit', async () => {
createWrapper();
wrapper.vm.$refs.dropdown.show = jest.fn();
await findBoardEditableItem().vm.$emit('open');
expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1);
});
});
describe('when editing', () => {
beforeEach(() => {
createWrapper();
jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse');
});
it('collapses BoardEditableItem on clicking edit', async () => {
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
await findDropdown().vm.$emit('hide');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
});
it('renders "None" when no milestone is selected', () => {
createWrapper();
......
......@@ -3,12 +3,13 @@
require 'spec_helper'
RSpec.describe 'validate puma' do
include RakeHelpers
subject do
load Rails.root.join('config/initializers/validate_puma.rb')
end
before do
stub_env('PUMA_SKIP_CLUSTER_VALIDATION', skip_validation)
stub_const('Puma', double)
allow(Gitlab::Runtime).to receive(:puma?).and_return(true)
allow(Puma).to receive_message_chain(:cli_config, :options).and_return(workers: workers)
......@@ -22,70 +23,44 @@ RSpec.describe 'validate puma' do
context 'when worker count is 0' do
let(:workers) { 0 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.to raise_error(String) }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
specify { expect { subject }.to raise_error(String) }
end
specify { expect { subject }.to raise_error(String) }
end
context 'when worker count is > 0' do
let(:workers) { 2 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
specify { expect { subject }.not_to raise_error }
end
specify { expect { subject }.not_to raise_error }
end
end
context 'for other environments' do
before do
allow(Gitlab).to receive(:com?).and_return(false)
allow(main_object).to receive(:warn)
end
context 'when worker count is 0' do
let(:workers) { 0 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error }
end
specify { expect { subject }.not_to raise_error }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
it 'warns about running Puma in a Single mode' do
expect(main_object).to receive(:warn) do |warning|
expect(warning).to include('https://gitlab.com/groups/gitlab-org/-/epics/5303')
end
specify { expect { subject }.to raise_error(String) }
subject
end
end
context 'when worker count is > 0' do
let(:workers) { 2 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
specify { expect { subject }.not_to raise_error }
specify { expect { subject }.not_to raise_error }
it 'does not issue a warning' do
expect(main_object).not_to receive(:warn)
end
end
end
......
......@@ -221,6 +221,22 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
end
end
describe '.track_marked_as_draft_action' do
subject { described_class.track_marked_as_draft_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_MARKED_AS_DRAFT_ACTION }
end
end
describe '.track_unmarked_as_draft_action' do
subject { described_class.track_unmarked_as_draft_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_UNMARKED_AS_DRAFT_ACTION }
end
end
describe '.track_users_review_requested' do
subject { described_class.track_users_review_requested(users: [user]) }
......
......@@ -629,25 +629,4 @@ RSpec.describe DesignManagement::Design do
end
end
end
describe '#immediately_before' do
let_it_be(:design) { create(:design, issue: issue, relative_position: 100) }
let_it_be(:next_design) { create(:design, issue: issue, relative_position: 200) }
it 'is true when there is no element positioned between this item and the next' do
expect(design.immediately_before?(next_design)).to be true
end
it 'is false when there is an element positioned between this item and the next' do
create(:design, issue: issue, relative_position: 150)
expect(design.immediately_before?(next_design)).to be false
end
it 'is false when the next design is to the left of this design' do
further_left = create(:design, issue: issue, relative_position: 50)
expect(design.immediately_before?(further_left)).to be false
end
end
end
......@@ -1322,7 +1322,16 @@ RSpec.describe API::MergeRequests do
end
context 'Work in Progress' do
let!(:merge_request_wip) { create(:merge_request, author: user, assignees: [user], source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) }
let!(:merge_request_wip) do
create(:merge_request,
author: user,
assignees: [user],
source_project: project,
target_project: project,
title: "WIP: Test",
created_at: base_time + 1.second
)
end
it "returns merge request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user)
......
......@@ -76,18 +76,6 @@ RSpec.describe DesignManagement::MoveDesignsService do
end
end
context 'the designs are not adjacent' do
let(:current_design) { designs.first }
let(:previous_design) { designs.second }
let(:next_design) { designs.third }
it 'raises not_adjacent' do
create(:design, issue: issue, relative_position: next_design.relative_position - 1)
expect(subject).to be_error.and(have_attributes(message: :not_adjacent))
end
end
context 'moving a design with neighbours' do
let(:current_design) { designs.first }
let(:previous_design) { designs.second }
......
......@@ -89,6 +89,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
context 'usage counters' do
let(:merge_request2) { create(:merge_request) }
let(:draft_merge_request) { create(:merge_request, :draft_merge_request)}
it 'update as expected' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
......@@ -98,6 +99,24 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request2)
end
it 'tracks Draft/WIP marking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_marked_as_draft_action).once.with(user: user)
opts[:title] = "WIP: #{opts[:title]}"
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request2)
end
it 'tracks Draft/WIP un-marking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_unmarked_as_draft_action).once.with(user: user)
opts[:title] = "Non-draft/wip title string"
MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request)
end
end
context 'updating milestone' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment