Commit 2fcac2b2 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents f2182208 3ac72085
...@@ -47,6 +47,7 @@ module MergeRequests ...@@ -47,6 +47,7 @@ module MergeRequests
handle_draft_status_change(merge_request, changed_fields) handle_draft_status_change(merge_request, changed_fields)
track_title_and_desc_edits(changed_fields) track_title_and_desc_edits(changed_fields)
track_discussion_lock_toggle(merge_request, changed_fields)
notify_if_labels_added(merge_request, old_labels) notify_if_labels_added(merge_request, old_labels)
notify_if_mentions_added(merge_request, old_mentioned_users) notify_if_mentions_added(merge_request, old_mentioned_users)
...@@ -95,6 +96,16 @@ module MergeRequests ...@@ -95,6 +96,16 @@ module MergeRequests
end end
end end
def track_discussion_lock_toggle(merge_request, changed_fields)
return unless changed_fields.include?('discussion_locked')
if merge_request.discussion_locked
merge_request_activity_counter.track_discussion_locked_action(user: current_user)
else
merge_request_activity_counter.track_discussion_unlocked_action(user: current_user)
end
end
def notify_if_labels_added(merge_request, old_labels) def notify_if_labels_added(merge_request, old_labels)
added_labels = merge_request.labels - old_labels added_labels = merge_request.labels - old_labels
......
---
title: Track usage pings when MR gets locked/unlocked
merge_request: 55069
author:
type: other
---
title: Expose container_registry_image_prefix to project API
merge_request: 54090
author: Mathieu Parent
type: added
---
name: usage_data_i_code_review_user_mr_discussion_locked
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
rollout_issue_url:
milestone: '13.10'
type: development
group: group::code review
default_enabled: true
---
name: usage_data_i_code_review_user_mr_discussion_unlocked
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
rollout_issue_url:
milestone: '13.10'
type: development
group: group::code review
default_enabled: true
---
key_path: redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_monthly
description: Count of unique users per month who locked a MR
product_section: dev
product_stage: create
product_group: group::code review
product_category: code_review
value_type: number
status: implemented
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
time_frame: 28d
data_source: redis_hll
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
---
key_path: redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_monthly
description: Count of unique users per month who unlocked a MR
product_section: dev
product_stage: create
product_group: group::code review
product_category: code_review
value_type: number
status: implemented
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
time_frame: 28d
data_source: redis_hll
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
---
key_path: redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_weekly
description: Count of unique users per week who unlocked a MR
product_section: dev
product_stage: create
product_group: group::code review
product_category: code_review
value_type: number
status: implemented
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
time_frame: 7d
data_source: redis_hll
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
---
key_path: redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_weekly
description: Count of unique users per week who locked a MR
product_section: dev
product_stage: create
product_group: group::code review
product_category: code_review
value_type: number
status: implemented
milestone: "13.10"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069
time_frame: 7d
data_source: redis_hll
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
...@@ -179,6 +179,7 @@ When the user is authenticated and `simple` is not set this returns something li ...@@ -179,6 +179,7 @@ When the user is authenticated and `simple` is not set this returns something li
"packages_size": 0, "packages_size": 0,
"snippets_size": 0 "snippets_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -284,6 +285,7 @@ When the user is authenticated and `simple` is not set this returns something li ...@@ -284,6 +285,7 @@ When the user is authenticated and `simple` is not set this returns something li
"packages_size": 0, "packages_size": 0,
"snippets_size": 0 "snippets_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/brightbox/puppet",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -439,6 +441,7 @@ GET /users/:user_id/projects ...@@ -439,6 +441,7 @@ GET /users/:user_id/projects
"packages_size": 0, "packages_size": 0,
"snippets_size": 0 "snippets_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -544,6 +547,7 @@ GET /users/:user_id/projects ...@@ -544,6 +547,7 @@ GET /users/:user_id/projects
"packages_size": 0, "packages_size": 0,
"snippets_size": 0 "snippets_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/brightbox/puppet",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -658,6 +662,7 @@ Example response: ...@@ -658,6 +662,7 @@ Example response:
"lfs_objects_size": 0, "lfs_objects_size": 0,
"job_artifacts_size": 0 "job_artifacts_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -758,6 +763,7 @@ Example response: ...@@ -758,6 +763,7 @@ Example response:
"lfs_objects_size": 0, "lfs_objects_size": 0,
"job_artifacts_size": 0 "job_artifacts_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/brightbox/puppet",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -921,6 +927,7 @@ GET /projects/:id ...@@ -921,6 +927,7 @@ GET /projects/:id
"packages_size": 0, "packages_size": 0,
"snippets_size": 0 "snippets_size": 0
}, },
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -1373,6 +1380,7 @@ Example responses: ...@@ -1373,6 +1380,7 @@ Example responses:
"merge_method": "merge", "merge_method": "merge",
"autoclose_referenced_issues": true, "autoclose_referenced_issues": true,
"suggestion_commit_message": null, "suggestion_commit_message": null,
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -1467,6 +1475,7 @@ Example response: ...@@ -1467,6 +1475,7 @@ Example response:
"merge_method": "merge", "merge_method": "merge",
"autoclose_referenced_issues": true, "autoclose_referenced_issues": true,
"suggestion_commit_message": null, "suggestion_commit_message": null,
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -1559,6 +1568,7 @@ Example response: ...@@ -1559,6 +1568,7 @@ Example response:
"merge_method": "merge", "merge_method": "merge",
"autoclose_referenced_issues": true, "autoclose_referenced_issues": true,
"suggestion_commit_message": null, "suggestion_commit_message": null,
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -1745,6 +1755,7 @@ Example response: ...@@ -1745,6 +1755,7 @@ Example response:
"merge_method": "merge", "merge_method": "merge",
"autoclose_referenced_issues": true, "autoclose_referenced_issues": true,
"suggestion_commit_message": null, "suggestion_commit_message": null,
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -1858,6 +1869,7 @@ Example response: ...@@ -1858,6 +1869,7 @@ Example response:
"merge_method": "merge", "merge_method": "merge",
"autoclose_referenced_issues": true, "autoclose_referenced_issues": true,
"suggestion_commit_message": null, "suggestion_commit_message": null,
"container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site",
"_links": { "_links": {
"self": "http://example.com/api/v4/projects", "self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues", "issues": "http://example.com/api/v4/projects/1/issues",
...@@ -2354,6 +2366,7 @@ Example response: ...@@ -2354,6 +2366,7 @@ Example response:
"avatar_url": null, "avatar_url": null,
"web_url": "https://gitlab.example.com/groups/cute-cats" "web_url": "https://gitlab.example.com/groups/cute-cats"
}, },
"container_registry_image_prefix": "registry.example.com/cute-cats/hello-world",
"_links": { "_links": {
"self": "https://gitlab.example.com/api/v4/projects/7", "self": "https://gitlab.example.com/api/v4/projects/7",
"issues": "https://gitlab.example.com/api/v4/projects/7/issues", "issues": "https://gitlab.example.com/api/v4/projects/7/issues",
......
...@@ -13384,6 +13384,86 @@ Count of unique users per week|month who merged a MR ...@@ -13384,6 +13384,86 @@ Count of unique users per week|month who merged a MR
| `tier` | | | `tier` | |
| `skip_validation` | true | | `skip_validation` | true |
## `redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_monthly`
Count of unique users per month who locked a MR
| field | value |
| --- | --- |
| `key_path` | **`redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_monthly`** |
| `product_section` | dev |
| `product_stage` | create |
| `product_group` | `group::code review` |
| `product_category` | `code_review` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069) |
| `time_frame` | 28d |
| `data_source` | Redis_hll |
| `distribution` | ce, ee |
| `tier` | free, premium, ultimate |
## `redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_weekly`
Count of unique users per week who locked a MR
| field | value |
| --- | --- |
| `key_path` | **`redis_hll_counters.code_review.i_code_review_user_mr_discussion_locked_weekly`** |
| `product_section` | dev |
| `product_stage` | create |
| `product_group` | `group::code review` |
| `product_category` | `code_review` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069) |
| `time_frame` | 7d |
| `data_source` | Redis_hll |
| `distribution` | ce, ee |
| `tier` | free, premium, ultimate |
## `redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_monthly`
Count of unique users per month who unlocked a MR
| field | value |
| --- | --- |
| `key_path` | **`redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_monthly`** |
| `product_section` | dev |
| `product_stage` | create |
| `product_group` | `group::code review` |
| `product_category` | `code_review` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069) |
| `time_frame` | 28d |
| `data_source` | Redis_hll |
| `distribution` | ce, ee |
| `tier` | free, premium, ultimate |
## `redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_weekly`
Count of unique users per week who unlocked a MR
| field | value |
| --- | --- |
| `key_path` | **`redis_hll_counters.code_review.i_code_review_user_mr_discussion_unlocked_weekly`** |
| `product_section` | dev |
| `product_stage` | create |
| `product_group` | `group::code review` |
| `product_category` | `code_review` |
| `value_type` | number |
| `status` | implemented |
| `milestone` | 13.10 |
| `introduced_by_url` | [Introduced by](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55069) |
| `time_frame` | 7d |
| `data_source` | Redis_hll |
| `distribution` | ce, ee |
| `tier` | free, premium, ultimate |
## `redis_hll_counters.code_review.i_code_review_user_publish_review_monthly` ## `redis_hll_counters.code_review.i_code_review_user_publish_review_monthly`
Missing description Missing description
......
...@@ -40,9 +40,10 @@ export const i18n = { ...@@ -40,9 +40,10 @@ export const i18n = {
title: __('Starts on'), title: __('Starts on'),
error: s__('OnCallSchedules|Rotation start date cannot be empty'), error: s__('OnCallSchedules|Rotation start date cannot be empty'),
}, },
endsOn: { endsAt: {
enableToggle: s__('OnCallSchedules|Enable end date'), enableToggle: s__('OnCallSchedules|Enable end date'),
title: __('Ends on'), title: __('Ends on'),
error: s__('OnCallSchedules|Rotation end date/time must come after start date/time'),
}, },
restrictToTime: { restrictToTime: {
enableToggle: s__('OnCallSchedules|Restrict to time intervals'), enableToggle: s__('OnCallSchedules|Restrict to time intervals'),
...@@ -234,7 +235,7 @@ export default { ...@@ -234,7 +235,7 @@ export default {
<div class="gl-display-inline-block"> <div class="gl-display-inline-block">
<gl-toggle <gl-toggle
v-model="endDateEnabled" v-model="endDateEnabled"
:label="$options.i18n.fields.endsOn.enableToggle" :label="$options.i18n.fields.endsAt.enableToggle"
label-position="left" label-position="left"
class="gl-mb-5" class="gl-mb-5"
/> />
...@@ -245,28 +246,43 @@ export default { ...@@ -245,28 +246,43 @@ export default {
class="gl-border-gray-400 gl-bg-gray-10" class="gl-border-gray-400 gl-bg-gray-10"
> >
<gl-form-group <gl-form-group
:label="$options.i18n.fields.endsOn.title" :label="$options.i18n.fields.endsAt.title"
label-size="sm" label-size="sm"
:invalid-feedback="$options.i18n.fields.endsOn.error" :state="validationState.endsAt"
:invalid-feedback="$options.i18n.fields.endsAt.error"
class="gl-mb-0" class="gl-mb-0"
> >
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<gl-datepicker <gl-datepicker
class="gl-mr-3" class="gl-mr-3"
@input="$emit('update-rotation-form', { type: 'endsOn.date', value: $event })" @input="$emit('update-rotation-form', { type: 'endsAt.date', value: $event })"
/> >
<template #default="{ formattedDate }">
<gl-form-input
class="gl-w-full"
:value="formattedDate"
:placeholder="__(`YYYY-MM-DD`)"
@blur="
$emit('update-rotation-form', {
type: 'endsAt.date',
value: $event.target.value,
})
"
/>
</template>
</gl-datepicker>
<span> {{ __('at') }} </span> <span> {{ __('at') }} </span>
<gl-dropdown <gl-dropdown
data-testid="rotation-end-time" data-testid="rotation-end-time"
:text="format24HourTimeStringFromInt(form.endsOn.time)" :text="format24HourTimeStringFromInt(form.endsAt.time)"
class="gl-px-3" class="gl-px-3"
> >
<gl-dropdown-item <gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY" v-for="time in $options.HOURS_IN_DAY"
:key="time" :key="time"
:is-checked="form.endsOn.time === time" :is-checked="form.endsAt.time === time"
is-check-item is-check-item
@click="$emit('update-rotation-form', { type: 'endsOn.time', value: time })" @click="$emit('update-rotation-form', { type: 'endsAt.time', value: time })"
> >
<span class="gl-white-space-nowrap"> <span class="gl-white-space-nowrap">
{{ format24HourTimeStringFromInt(time) }}</span {{ format24HourTimeStringFromInt(time) }}</span
...@@ -294,7 +310,7 @@ export default { ...@@ -294,7 +310,7 @@ export default {
<gl-form-group <gl-form-group
:label="$options.i18n.fields.restrictToTime.title" :label="$options.i18n.fields.restrictToTime.title"
label-size="sm" label-size="sm"
:invalid-feedback="$options.i18n.fields.endsOn.error" :invalid-feedback="$options.i18n.fields.endsAt.error"
class="gl-mb-0" class="gl-mb-0"
> >
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
......
...@@ -78,7 +78,7 @@ export default { ...@@ -78,7 +78,7 @@ export default {
date: null, date: null,
time: 0, time: 0,
}, },
endsOn: { endsAt: {
date: null, date: null,
time: 0, time: 0,
}, },
...@@ -92,6 +92,7 @@ export default { ...@@ -92,6 +92,7 @@ export default {
name: true, name: true,
participants: true, participants: true,
startsAt: true, startsAt: true,
endsAt: true,
}, },
}; };
}, },
...@@ -129,7 +130,8 @@ export default { ...@@ -129,7 +130,8 @@ export default {
name, name,
rotationLength, rotationLength,
participants, participants,
startsAt: { date, time }, startsAt: { date: startDate, time: startTime },
endsAt: { date: endDate, time: endTime },
} = this.form; } = this.form;
return { return {
...@@ -137,9 +139,15 @@ export default { ...@@ -137,9 +139,15 @@ export default {
scheduleIid: this.schedule.iid, scheduleIid: this.schedule.iid,
name, name,
startsAt: { startsAt: {
date: formatDate(date, 'yyyy-mm-dd'), date: formatDate(startDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(time), time: format24HourTimeStringFromInt(startTime),
}, },
endsAt: endDate
? {
date: formatDate(endDate, 'yyyy-mm-dd'),
time: format24HourTimeStringFromInt(endTime),
}
: null,
rotationLength: { rotationLength: {
...rotationLength, ...rotationLength,
length: parseInt(rotationLength.length, 10), length: parseInt(rotationLength.length, 10),
...@@ -150,6 +158,20 @@ export default { ...@@ -150,6 +158,20 @@ export default {
title() { title() {
return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation; return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation;
}, },
isEndDateValid() {
const startsAt = this.form.startsAt.date?.getTime();
const endsAt = this.form.endsAt.date?.getTime();
if (!startsAt || !endsAt) {
// If start or end is not present, we consider the end date valid
return true;
} else if (startsAt < endsAt) {
return true;
} else if (startsAt === endsAt) {
return this.form.startsAt.time < this.form.endsAt.time;
}
return false;
},
}, },
methods: { methods: {
createRotation() { createRotation() {
...@@ -244,8 +266,11 @@ export default { ...@@ -244,8 +266,11 @@ export default {
this.validationState.name = isNameFieldValid(this.form.name); this.validationState.name = isNameFieldValid(this.form.name);
} else if (key === 'participants') { } else if (key === 'participants') {
this.validationState.participants = this.form.participants.length > 0; this.validationState.participants = this.form.participants.length > 0;
} else if (key === 'startsAt.date') { } else if (key === 'startsAt.date' || key === 'startsAt.time') {
this.validationState.startsAt = Boolean(this.form.startsAt.date); this.validationState.startsAt = Boolean(this.form.startsAt.date);
this.validationState.endsAt = this.isEndDateValid;
} else if (key === 'endsAt.date' || key === 'endsAt.time') {
this.validationState.endsAt = this.isEndDateValid;
} }
}, },
}, },
......
...@@ -4,6 +4,7 @@ fragment OnCallRotation on IncidentManagementOncallRotation { ...@@ -4,6 +4,7 @@ fragment OnCallRotation on IncidentManagementOncallRotation {
id id
name name
startsAt startsAt
endsAt
length length
lengthUnit lengthUnit
participants { participants {
......
...@@ -171,7 +171,13 @@ export default { ...@@ -171,7 +171,13 @@ export default {
</gl-sprintf> </gl-sprintf>
</gl-alert> </gl-alert>
<gl-table ref="securityControlTable" :items="features" :fields="fields" stacked="md"> <gl-table
ref="securityControlTable"
:items="features"
:fields="fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
>
<template #cell(feature)="{ item }"> <template #cell(feature)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div> <div class="gl-text-gray-900">{{ item.name }}</div>
<div> <div>
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User sees Security Configuration table', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
before_all do
project.add_developer(user)
end
before do
sign_in(user)
end
context 'with security_dashboard feature available' do
before do
stub_licensed_features(security_dashboard: true)
end
context 'with no SAST report' do
it 'shows SAST is not enabled' do
visit(project_security_configuration_path(project))
within_sast_row do
expect(page).to have_text('SAST')
expect(page).to have_text('Not enabled')
expect(page).to have_css('[data-testid="enableButton"]')
end
end
end
context 'with SAST report' do
before do
pipeline = create(:ci_pipeline, project: project)
create(:ci_build, :sast, pipeline: pipeline, status: 'success')
end
it 'shows SAST is enabled' do
visit(project_security_configuration_path(project))
within_sast_row do
expect(page).to have_text('SAST')
expect(page).to have_text('Enabled')
expect(page).to have_css('[data-testid="configureButton"]')
end
end
end
end
def within_sast_row
within '[data-testid="security-scanner-row"]:nth-of-type(1)' do
yield
end
end
end
...@@ -138,7 +138,8 @@ export const createRotationResponse = { ...@@ -138,7 +138,8 @@ export const createRotationResponse = {
oncallRotation: { oncallRotation: {
id: '44', id: '44',
name: 'Test', name: 'Test',
startsAt: '2020-12-17T12:00:00Z', startsAt: '2020-12-20T12:00:00Z',
endsAt: '2021-03-17T12:00:00Z',
length: 5, length: 5,
lengthUnit: 'WEEKS', lengthUnit: 'WEEKS',
participants: { participants: {
...@@ -171,7 +172,8 @@ export const createRotationResponseWithErrors = { ...@@ -171,7 +172,8 @@ export const createRotationResponseWithErrors = {
oncallRotation: { oncallRotation: {
id: '44', id: '44',
name: 'Test', name: 'Test',
startsAt: '2020-12-17T12:00:00Z', startsAt: '2020-12-20T12:00:00Z',
endsAt: '2021-03-17T12:00:00Z',
length: 5, length: 5,
lengthUnit: 'WEEKS', lengthUnit: 'WEEKS',
participants: { participants: {
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"id": "gid://gitlab/IncidentManagement::OncallRotation/2", "id": "gid://gitlab/IncidentManagement::OncallRotation/2",
"name": "Rotation 242", "name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z", "startsAt": "2021-01-13T10:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z",
"length": 1, "length": 1,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"participants": { "participants": {
...@@ -54,6 +55,7 @@ ...@@ -54,6 +55,7 @@
"id": "gid://gitlab/IncidentManagement::OncallRotation/55", "id": "gid://gitlab/IncidentManagement::OncallRotation/55",
"name": "Rotation 242", "name": "Rotation 242",
"startsAt": "2021-01-13T10:04:56.333Z", "startsAt": "2021-01-13T10:04:56.333Z",
"endsAt": "2021-03-13T10:04:56.333Z",
"length": 1, "length": 1,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"participants": { "participants": {
...@@ -102,6 +104,7 @@ ...@@ -102,6 +104,7 @@
"id": "gid://gitlab/IncidentManagement::OncallRotation/3", "id": "gid://gitlab/IncidentManagement::OncallRotation/3",
"name": "Rotation 244", "name": "Rotation 244",
"startsAt": "2021-01-06T10:04:56.333Z", "startsAt": "2021-01-06T10:04:56.333Z",
"endsAt": "2021-01-10T10:04:56.333Z",
"length": 1, "length": 1,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"participants": { "participants": {
...@@ -150,6 +153,7 @@ ...@@ -150,6 +153,7 @@
"id": "gid://gitlab/IncidentManagement::OncallRotation/5", "id": "gid://gitlab/IncidentManagement::OncallRotation/5",
"name": "Rotation 247", "name": "Rotation 247",
"startsAt": "2021-01-06T10:04:56.333Z", "startsAt": "2021-01-06T10:04:56.333Z",
"endsAt": "2021-01-11T10:04:56.333Z",
"length": 1, "length": 1,
"lengthUnit": "WEEKS", "lengthUnit": "WEEKS",
"participants": { "participants": {
......
...@@ -40,7 +40,7 @@ describe('AddEditRotationForm', () => { ...@@ -40,7 +40,7 @@ describe('AddEditRotationForm', () => {
date: null, date: null,
time: 0, time: 0,
}, },
endsOn: { endsAt: {
date: null, date: null,
time: 0, time: 0,
}, },
...@@ -160,7 +160,7 @@ describe('AddEditRotationForm', () => { ...@@ -160,7 +160,7 @@ describe('AddEditRotationForm', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const emittedEvent = wrapper.emitted('update-rotation-form'); const emittedEvent = wrapper.emitted('update-rotation-form');
expect(emittedEvent).toHaveLength(1); expect(emittedEvent).toHaveLength(1);
expect(emittedEvent[0][0]).toEqual({ type: 'endsOn.time', value: option + 1 }); expect(emittedEvent[0][0]).toEqual({ type: 'endsAt.time', value: option + 1 });
}); });
it('should add a checkmark to a selected end time', async () => { it('should add a checkmark to a selected end time', async () => {
...@@ -168,7 +168,7 @@ describe('AddEditRotationForm', () => { ...@@ -168,7 +168,7 @@ describe('AddEditRotationForm', () => {
const time = 5; const time = 5;
wrapper.setProps({ wrapper.setProps({
form: { form: {
endsOn: { endsAt: {
time, time,
}, },
startsAt: { startsAt: {
...@@ -221,7 +221,7 @@ describe('AddEditRotationForm', () => { ...@@ -221,7 +221,7 @@ describe('AddEditRotationForm', () => {
wrapper.setProps({ wrapper.setProps({
form: { form: {
endsOn: { endsAt: {
time: 0, time: 0,
}, },
startsAt: { startsAt: {
......
import { GlModal, GlAlert } from '@gitlab/ui'; import { GlModal, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import AddEditRotationForm from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue';
import AddEditRotationModal, { import AddEditRotationModal, {
i18n, i18n,
} from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue'; } from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue';
...@@ -129,8 +130,9 @@ describe('AddEditRotationModal', () => { ...@@ -129,8 +130,9 @@ describe('AddEditRotationModal', () => {
wrapper = null; wrapper = null;
}); });
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findForm = () => wrapper.findComponent(AddEditRotationForm);
it('renders rotation modal layout', () => { it('renders rotation modal layout', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
...@@ -155,6 +157,149 @@ describe('AddEditRotationModal', () => { ...@@ -155,6 +157,149 @@ describe('AddEditRotationModal', () => {
expect(findAlert().exists()).toBe(true); expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toContain(error); expect(findAlert().text()).toContain(error);
}); });
describe('Validation', () => {
describe('name', () => {
it('is valid when name is NOT empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'name', value: '' });
expect(form.props('validationState').name).toBe(false);
});
it('is NOT valid when name is empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'name', value: 'Some value' });
expect(form.props('validationState').name).toBe(true);
});
});
describe('participants', () => {
it('is valid when participants array is NOT empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'participants',
value: ['user1', 'user2'],
});
expect(form.props('validationState').participants).toBe(true);
});
it('is NOT valid when participants array is empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'participants', value: [] });
expect(form.props('validationState').participants).toBe(false);
});
});
describe('startsAt date', () => {
it('is valid when date is NOT empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('10/12/2021'),
});
expect(form.props('validationState').startsAt).toBe(true);
});
it('is NOT valid when date is empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'startsAt.time', value: null });
expect(form.props('validationState').startsAt).toBe(false);
});
});
describe('endsAt date', () => {
it('is valid when date is empty', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'endsAt.date', value: null });
expect(form.props('validationState').endsAt).toBe(true);
});
it('is valid when start date is smaller then end date', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('9/11/2021'),
});
form.vm.$emit('update-rotation-form', {
type: 'endsAt.date',
value: new Date('10/11/2021'),
});
expect(form.props('validationState').endsAt).toBe(true);
});
it('is invalid when start date is larger then end date', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('11/11/2021'),
});
form.vm.$emit('update-rotation-form', {
type: 'endsAt.date',
value: new Date('10/11/2021'),
});
expect(form.props('validationState').endsAt).toBe(false);
});
it('is valid when start and end dates are equal but time is smaller on start date', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('11/11/2021'),
});
form.vm.$emit('update-rotation-form', { type: 'startsAt.time', value: 10 });
form.vm.$emit('update-rotation-form', {
type: 'endsAt.date',
value: new Date('11/11/2021'),
});
form.vm.$emit('update-rotation-form', { type: 'endsAt.time', value: 22 });
expect(form.props('validationState').endsAt).toBe(true);
});
it('is invalid when start and end dates are equal but time is larger on start date', () => {
const form = findForm();
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('11/11/2021'),
});
form.vm.$emit('update-rotation-form', { type: 'startsAt.time', value: 10 });
form.vm.$emit('update-rotation-form', {
type: 'endsAt.date',
value: new Date('11/11/2021'),
});
form.vm.$emit('update-rotation-form', { type: 'endsAt.time', value: 8 });
expect(form.props('validationState').endsAt).toBe(false);
});
});
describe('Toggle primary button state', () => {
it('should disable primary button when any of the fields is invalid', async () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'name', value: 'lalal' });
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary').attributes).toEqual(
expect.arrayContaining([{ disabled: true }]),
);
});
it('should enable primary button when all fields are valid', async () => {
const form = findForm();
form.vm.$emit('update-rotation-form', { type: 'name', value: 'Value' });
form.vm.$emit('update-rotation-form', { type: 'participants', value: [1, 2, 3] });
form.vm.$emit('update-rotation-form', {
type: 'startsAt.date',
value: new Date('11/10/2021'),
});
form.vm.$emit('update-rotation-form', {
type: 'endsAt.date',
value: new Date('12/10/2021'),
});
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary').attributes).toEqual(
expect.arrayContaining([{ disabled: false }]),
);
});
});
});
}); });
describe('with mocked Apollo client', () => { describe('with mocked Apollo client', () => {
......
...@@ -5,6 +5,8 @@ module API ...@@ -5,6 +5,8 @@ module API
class Project < BasicProjectDetails class Project < BasicProjectDetails
include ::API::Helpers::RelatedResourcesHelpers include ::API::Helpers::RelatedResourcesHelpers
expose :container_registry_url, as: :container_registry_image_prefix, if: -> (_, _) { Gitlab.config.registry.enabled }
expose :_links do expose :_links do
expose :self do |project| expose :self do |project|
expose_url(api_v4_projects_path(id: project.id)) expose_url(api_v4_projects_path(id: project.id))
......
...@@ -42,7 +42,9 @@ ...@@ -42,7 +42,9 @@
'i_code_review_user_approval_rule_edited', 'i_code_review_user_approval_rule_edited',
'i_code_review_user_vs_code_api_request', 'i_code_review_user_vs_code_api_request',
'i_code_review_user_toggled_task_item_status', 'i_code_review_user_toggled_task_item_status',
'i_code_review_user_create_mr_from_issue' 'i_code_review_user_create_mr_from_issue',
'i_code_review_user_mr_discussion_locked',
'i_code_review_user_mr_discussion_unlocked'
] ]
- name: code_review_category_monthly_active_users - name: code_review_category_monthly_active_users
operator: OR operator: OR
...@@ -78,7 +80,9 @@ ...@@ -78,7 +80,9 @@
'i_code_review_user_approval_rule_deleted', 'i_code_review_user_approval_rule_deleted',
'i_code_review_user_approval_rule_edited', 'i_code_review_user_approval_rule_edited',
'i_code_review_user_toggled_task_item_status', 'i_code_review_user_toggled_task_item_status',
'i_code_review_user_create_mr_from_issue' 'i_code_review_user_create_mr_from_issue',
'i_code_review_user_mr_discussion_locked',
'i_code_review_user_mr_discussion_unlocked'
] ]
- name: code_review_extension_category_monthly_active_users - name: code_review_extension_category_monthly_active_users
operator: OR operator: OR
......
...@@ -164,3 +164,13 @@ ...@@ -164,3 +164,13 @@
category: code_review category: code_review
aggregation: weekly aggregation: weekly
feature_flag: usage_data_i_code_review_user_create_mr_from_issue feature_flag: usage_data_i_code_review_user_create_mr_from_issue
- name: i_code_review_user_mr_discussion_locked
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_mr_discussion_locked
- name: i_code_review_user_mr_discussion_unlocked
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_mr_discussion_unlocked
...@@ -35,6 +35,8 @@ module Gitlab ...@@ -35,6 +35,8 @@ module Gitlab
MR_EDIT_MR_TITLE_ACTION = 'i_code_review_edit_mr_title' MR_EDIT_MR_TITLE_ACTION = 'i_code_review_edit_mr_title'
MR_EDIT_MR_DESC_ACTION = 'i_code_review_edit_mr_desc' MR_EDIT_MR_DESC_ACTION = 'i_code_review_edit_mr_desc'
MR_CREATE_FROM_ISSUE_ACTION = 'i_code_review_user_create_mr_from_issue' MR_CREATE_FROM_ISSUE_ACTION = 'i_code_review_user_create_mr_from_issue'
MR_DISCUSSION_LOCKED_ACTION = 'i_code_review_user_mr_discussion_locked'
MR_DISCUSSION_UNLOCKED_ACTION = 'i_code_review_user_mr_discussion_unlocked'
class << self class << self
def track_mr_diffs_action(merge_request:) def track_mr_diffs_action(merge_request:)
...@@ -153,6 +155,14 @@ module Gitlab ...@@ -153,6 +155,14 @@ module Gitlab
track_unique_action_by_user(MR_CREATE_FROM_ISSUE_ACTION, user) track_unique_action_by_user(MR_CREATE_FROM_ISSUE_ACTION, user)
end end
def track_discussion_locked_action(user:)
track_unique_action_by_user(MR_DISCUSSION_LOCKED_ACTION, user)
end
def track_discussion_unlocked_action(user:)
track_unique_action_by_user(MR_DISCUSSION_UNLOCKED_ACTION, user)
end
private private
def track_unique_action_by_merge_request(action, merge_request) def track_unique_action_by_merge_request(action, merge_request)
......
...@@ -21028,6 +21028,9 @@ msgstr "" ...@@ -21028,6 +21028,9 @@ msgstr ""
msgid "OnCallSchedules|Restrict to time intervals" msgid "OnCallSchedules|Restrict to time intervals"
msgstr "" msgstr ""
msgid "OnCallSchedules|Rotation end date/time must come after start date/time"
msgstr ""
msgid "OnCallSchedules|Rotation length" msgid "OnCallSchedules|Rotation length"
msgstr "" msgstr ""
......
...@@ -284,4 +284,20 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl ...@@ -284,4 +284,20 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
let(:action) { described_class::MR_CREATE_FROM_ISSUE_ACTION } let(:action) { described_class::MR_CREATE_FROM_ISSUE_ACTION }
end end
end end
describe '.track_discussion_locked_action' do
subject { described_class.track_discussion_locked_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_DISCUSSION_LOCKED_ACTION }
end
end
describe '.track_discussion_unlocked_action' do
subject { described_class.track_discussion_unlocked_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_DISCUSSION_UNLOCKED_ACTION }
end
end
end end
...@@ -56,6 +56,7 @@ itself: # project ...@@ -56,6 +56,7 @@ itself: # project
- can_create_merge_request_in - can_create_merge_request_in
- compliance_frameworks - compliance_frameworks
- container_expiration_policy - container_expiration_policy
- container_registry_image_prefix
- default_branch - default_branch
- empty_repo - empty_repo
- forks_count - forks_count
......
...@@ -1540,6 +1540,10 @@ RSpec.describe API::Projects do ...@@ -1540,6 +1540,10 @@ RSpec.describe API::Projects do
end end
context 'when authenticated as an admin' do context 'when authenticated as an admin' do
before do
stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000')
end
let(:project_attributes_file) { 'spec/requests/api/project_attributes.yml' } let(:project_attributes_file) { 'spec/requests/api/project_attributes.yml' }
let(:project_attributes) { YAML.load_file(project_attributes_file) } let(:project_attributes) { YAML.load_file(project_attributes_file) }
...@@ -1569,7 +1573,7 @@ RSpec.describe API::Projects do ...@@ -1569,7 +1573,7 @@ RSpec.describe API::Projects do
keys keys
end end
it 'returns a project by id' do it 'returns a project by id', :aggregate_failures do
project project
project_member project_member
group = create(:group) group = create(:group)
...@@ -1587,6 +1591,7 @@ RSpec.describe API::Projects do ...@@ -1587,6 +1591,7 @@ RSpec.describe API::Projects do
expect(json_response['ssh_url_to_repo']).to be_present expect(json_response['ssh_url_to_repo']).to be_present
expect(json_response['http_url_to_repo']).to be_present expect(json_response['http_url_to_repo']).to be_present
expect(json_response['web_url']).to be_present expect(json_response['web_url']).to be_present
expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}")
expect(json_response['owner']).to be_a Hash expect(json_response['owner']).to be_a Hash
expect(json_response['name']).to eq(project.name) expect(json_response['name']).to eq(project.name)
expect(json_response['path']).to be_present expect(json_response['path']).to be_present
...@@ -1644,9 +1649,10 @@ RSpec.describe API::Projects do ...@@ -1644,9 +1649,10 @@ RSpec.describe API::Projects do
before do before do
project project
project_member project_member
stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000')
end end
it 'returns a project by id' do it 'returns a project by id', :aggregate_failures do
group = create(:group) group = create(:group)
link = create(:project_group_link, project: project, group: group) link = create(:project_group_link, project: project, group: group)
...@@ -1662,6 +1668,7 @@ RSpec.describe API::Projects do ...@@ -1662,6 +1668,7 @@ RSpec.describe API::Projects do
expect(json_response['ssh_url_to_repo']).to be_present expect(json_response['ssh_url_to_repo']).to be_present
expect(json_response['http_url_to_repo']).to be_present expect(json_response['http_url_to_repo']).to be_present
expect(json_response['web_url']).to be_present expect(json_response['web_url']).to be_present
expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}")
expect(json_response['owner']).to be_a Hash expect(json_response['owner']).to be_a Hash
expect(json_response['name']).to eq(project.name) expect(json_response['name']).to eq(project.name)
expect(json_response['path']).to be_present expect(json_response['path']).to be_present
......
...@@ -48,6 +48,8 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -48,6 +48,8 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end end
context 'valid params' do context 'valid params' do
let(:locked) { true }
let(:opts) do let(:opts) do
{ {
title: 'New title', title: 'New title',
...@@ -58,7 +60,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -58,7 +60,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
label_ids: [label.id], label_ids: [label.id],
target_branch: 'target', target_branch: 'target',
force_remove_source_branch: '1', force_remove_source_branch: '1',
discussion_locked: true discussion_locked: locked
} }
end end
...@@ -117,6 +119,56 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -117,6 +119,56 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request) MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request)
end end
context 'when MR is locked' do
context 'when locked again' do
it 'does not track discussion locking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.not_to receive(:track_discussion_locked_action)
opts[:discussion_locked] = true
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
end
context 'when unlocked' do
it 'tracks dicussion unlocking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_discussion_unlocked_action).once.with(user: user)
opts[:discussion_locked] = false
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
end
end
context 'when MR is unlocked' do
let(:locked) { false }
context 'when unlocked again' do
it 'does not track discussion unlocking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.not_to receive(:track_discussion_unlocked_action)
opts[:discussion_locked] = false
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
end
context 'when locked' do
it 'tracks dicussion locking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_discussion_locked_action).once.with(user: user)
opts[:discussion_locked] = true
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
end
end
end
end end
context 'updating milestone' do 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