diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 38df3ee0e43d09788087b6593e8a5b0b52f6a353..217679e733c35429bcc5560c7925df7bce94ac5d 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -47,6 +47,7 @@ def handle_changes(merge_request, options) handle_draft_status_change(merge_request, 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_mentions_added(merge_request, old_mentioned_users) @@ -95,6 +96,16 @@ def track_title_and_desc_edits(changed_fields) 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) added_labels = merge_request.labels - old_labels diff --git a/changelogs/unreleased/292824-track-mr-lock-changes.yml b/changelogs/unreleased/292824-track-mr-lock-changes.yml new file mode 100644 index 0000000000000000000000000000000000000000..89102528aed349a77effa2b019975b4363f8fd39 --- /dev/null +++ b/changelogs/unreleased/292824-track-mr-lock-changes.yml @@ -0,0 +1,5 @@ +--- +title: Track usage pings when MR gets locked/unlocked +merge_request: 55069 +author: +type: other diff --git a/changelogs/unreleased/expose_project_container_registry_url.yml b/changelogs/unreleased/expose_project_container_registry_url.yml new file mode 100644 index 0000000000000000000000000000000000000000..923d14ed5a6f6c84b3fbd76552c9996540ca5402 --- /dev/null +++ b/changelogs/unreleased/expose_project_container_registry_url.yml @@ -0,0 +1,5 @@ +--- +title: Expose container_registry_image_prefix to project API +merge_request: 54090 +author: Mathieu Parent +type: added diff --git a/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_locked.yml b/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_locked.yml new file mode 100644 index 0000000000000000000000000000000000000000..e6e81f48028f2efc18494086e48ac3e66e34622a --- /dev/null +++ b/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_locked.yml @@ -0,0 +1,8 @@ +--- +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 diff --git a/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_unlocked.yml b/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_unlocked.yml new file mode 100644 index 0000000000000000000000000000000000000000..03ec6cde34b1fe4a408042979041ddca1f5de492 --- /dev/null +++ b/config/feature_flags/development/usage_data_i_code_review_user_mr_discussion_unlocked.yml @@ -0,0 +1,8 @@ +--- +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 diff --git a/config/metrics/counts_28d/20210301103859_i_code_review_user_mr_discussion_locked_monthly.yml b/config/metrics/counts_28d/20210301103859_i_code_review_user_mr_discussion_locked_monthly.yml new file mode 100644 index 0000000000000000000000000000000000000000..9ad7ff81231ee03eff962e78af968b58e90c1201 --- /dev/null +++ b/config/metrics/counts_28d/20210301103859_i_code_review_user_mr_discussion_locked_monthly.yml @@ -0,0 +1,20 @@ +--- +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 diff --git a/config/metrics/counts_28d/20210301103925_i_code_review_user_mr_discussion_unlocked_monthly.yml b/config/metrics/counts_28d/20210301103925_i_code_review_user_mr_discussion_unlocked_monthly.yml new file mode 100644 index 0000000000000000000000000000000000000000..707a2fc76d10e46e5c0874c2c452fe53167b7705 --- /dev/null +++ b/config/metrics/counts_28d/20210301103925_i_code_review_user_mr_discussion_unlocked_monthly.yml @@ -0,0 +1,20 @@ +--- +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 diff --git a/config/metrics/counts_7d/20210302105258_i_code_review_user_mr_discussion_unlocked_weekly.yml b/config/metrics/counts_7d/20210302105258_i_code_review_user_mr_discussion_unlocked_weekly.yml new file mode 100644 index 0000000000000000000000000000000000000000..80471ed836ac1b8ead9420276f3ab51e989b988f --- /dev/null +++ b/config/metrics/counts_7d/20210302105258_i_code_review_user_mr_discussion_unlocked_weekly.yml @@ -0,0 +1,20 @@ +--- +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 diff --git a/config/metrics/counts_7d/20210302105318_i_code_review_user_mr_discussion_locked_weekly.yml b/config/metrics/counts_7d/20210302105318_i_code_review_user_mr_discussion_locked_weekly.yml new file mode 100644 index 0000000000000000000000000000000000000000..2295fb75a48abc13e736cc5dfccbd3f1c6f378c6 --- /dev/null +++ b/config/metrics/counts_7d/20210302105318_i_code_review_user_mr_discussion_locked_weekly.yml @@ -0,0 +1,20 @@ +--- +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 diff --git a/doc/api/projects.md b/doc/api/projects.md index b8687314697250d513d6442b6b6f81c69cf18662..da883d376b07e6f5fea56cec80a5fd9653f61e7d 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -179,6 +179,7 @@ When the user is authenticated and `simple` is not set this returns something li "packages_size": 0, "snippets_size": 0 }, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client", "_links": { "self": "http://example.com/api/v4/projects", "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 "packages_size": 0, "snippets_size": 0 }, + "container_registry_image_prefix": "registry.example.com/brightbox/puppet", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -439,6 +441,7 @@ GET /users/:user_id/projects "packages_size": 0, "snippets_size": 0 }, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -544,6 +547,7 @@ GET /users/:user_id/projects "packages_size": 0, "snippets_size": 0 }, + "container_registry_image_prefix": "registry.example.com/brightbox/puppet", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -658,6 +662,7 @@ Example response: "lfs_objects_size": 0, "job_artifacts_size": 0 }, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -758,6 +763,7 @@ Example response: "lfs_objects_size": 0, "job_artifacts_size": 0 }, + "container_registry_image_prefix": "registry.example.com/brightbox/puppet", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -921,6 +927,7 @@ GET /projects/:id "packages_size": 0, "snippets_size": 0 }, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-client", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1373,6 +1380,7 @@ Example responses: "merge_method": "merge", "autoclose_referenced_issues": true, "suggestion_commit_message": null, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1467,6 +1475,7 @@ Example response: "merge_method": "merge", "autoclose_referenced_issues": true, "suggestion_commit_message": null, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1559,6 +1568,7 @@ Example response: "merge_method": "merge", "autoclose_referenced_issues": true, "suggestion_commit_message": null, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1745,6 +1755,7 @@ Example response: "merge_method": "merge", "autoclose_referenced_issues": true, "suggestion_commit_message": null, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1858,6 +1869,7 @@ Example response: "merge_method": "merge", "autoclose_referenced_issues": true, "suggestion_commit_message": null, + "container_registry_image_prefix": "registry.example.com/diaspora/diaspora-project-site", "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -2354,6 +2366,7 @@ Example response: "avatar_url": null, "web_url": "https://gitlab.example.com/groups/cute-cats" }, + "container_registry_image_prefix": "registry.example.com/cute-cats/hello-world", "_links": { "self": "https://gitlab.example.com/api/v4/projects/7", "issues": "https://gitlab.example.com/api/v4/projects/7/issues", diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md index 512f31903c8bf592f68572805cc592911b4dc94c..93ce26fceb535a8399eb39abbcfc808782453588 100644 --- a/doc/development/usage_ping/dictionary.md +++ b/doc/development/usage_ping/dictionary.md @@ -13384,6 +13384,86 @@ Count of unique users per week|month who merged a MR | `tier` | | | `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` Missing description diff --git a/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue b/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue index 51bcb5cfcd5492d2deae8e89c6b3e40e13cc0b39..3954184de9cb2bee69a4f85f6ee4b6e577627c3d 100644 --- a/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue +++ b/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue @@ -40,9 +40,10 @@ export const i18n = { title: __('Starts on'), error: s__('OnCallSchedules|Rotation start date cannot be empty'), }, - endsOn: { + endsAt: { enableToggle: s__('OnCallSchedules|Enable end date'), title: __('Ends on'), + error: s__('OnCallSchedules|Rotation end date/time must come after start date/time'), }, restrictToTime: { enableToggle: s__('OnCallSchedules|Restrict to time intervals'), @@ -234,7 +235,7 @@ export default { <div class="gl-display-inline-block"> <gl-toggle v-model="endDateEnabled" - :label="$options.i18n.fields.endsOn.enableToggle" + :label="$options.i18n.fields.endsAt.enableToggle" label-position="left" class="gl-mb-5" /> @@ -245,28 +246,43 @@ export default { class="gl-border-gray-400 gl-bg-gray-10" > <gl-form-group - :label="$options.i18n.fields.endsOn.title" + :label="$options.i18n.fields.endsAt.title" label-size="sm" - :invalid-feedback="$options.i18n.fields.endsOn.error" + :state="validationState.endsAt" + :invalid-feedback="$options.i18n.fields.endsAt.error" class="gl-mb-0" > <div class="gl-display-flex gl-align-items-center"> <gl-datepicker 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> <gl-dropdown data-testid="rotation-end-time" - :text="format24HourTimeStringFromInt(form.endsOn.time)" + :text="format24HourTimeStringFromInt(form.endsAt.time)" class="gl-px-3" > <gl-dropdown-item v-for="time in $options.HOURS_IN_DAY" :key="time" - :is-checked="form.endsOn.time === time" + :is-checked="form.endsAt.time === time" 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"> {{ format24HourTimeStringFromInt(time) }}</span @@ -294,7 +310,7 @@ export default { <gl-form-group :label="$options.i18n.fields.restrictToTime.title" label-size="sm" - :invalid-feedback="$options.i18n.fields.endsOn.error" + :invalid-feedback="$options.i18n.fields.endsAt.error" class="gl-mb-0" > <div class="gl-display-flex gl-align-items-center"> diff --git a/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue b/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue index 4b01a6f41838f0ea0f918bee2de11a5572e8de4f..f7672bbfda992394904f5d8e486de6eba91dad2f 100644 --- a/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue +++ b/ee/app/assets/javascripts/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue @@ -78,7 +78,7 @@ export default { date: null, time: 0, }, - endsOn: { + endsAt: { date: null, time: 0, }, @@ -92,6 +92,7 @@ export default { name: true, participants: true, startsAt: true, + endsAt: true, }, }; }, @@ -129,7 +130,8 @@ export default { name, rotationLength, participants, - startsAt: { date, time }, + startsAt: { date: startDate, time: startTime }, + endsAt: { date: endDate, time: endTime }, } = this.form; return { @@ -137,9 +139,15 @@ export default { scheduleIid: this.schedule.iid, name, startsAt: { - date: formatDate(date, 'yyyy-mm-dd'), - time: format24HourTimeStringFromInt(time), + date: formatDate(startDate, 'yyyy-mm-dd'), + time: format24HourTimeStringFromInt(startTime), }, + endsAt: endDate + ? { + date: formatDate(endDate, 'yyyy-mm-dd'), + time: format24HourTimeStringFromInt(endTime), + } + : null, rotationLength: { ...rotationLength, length: parseInt(rotationLength.length, 10), @@ -150,6 +158,20 @@ export default { title() { 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: { createRotation() { @@ -244,8 +266,11 @@ export default { this.validationState.name = isNameFieldValid(this.form.name); } else if (key === 'participants') { 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.endsAt = this.isEndDateValid; + } else if (key === 'endsAt.date' || key === 'endsAt.time') { + this.validationState.endsAt = this.isEndDateValid; } }, }, diff --git a/ee/app/assets/javascripts/oncall_schedules/graphql/fragments/oncall_schedule_rotation.fragment.graphql b/ee/app/assets/javascripts/oncall_schedules/graphql/fragments/oncall_schedule_rotation.fragment.graphql index b762fd6c401d56882ae910f329add962df4b83a6..cfc34635eea1162fb9d246add4f64c46704ec02b 100644 --- a/ee/app/assets/javascripts/oncall_schedules/graphql/fragments/oncall_schedule_rotation.fragment.graphql +++ b/ee/app/assets/javascripts/oncall_schedules/graphql/fragments/oncall_schedule_rotation.fragment.graphql @@ -4,6 +4,7 @@ fragment OnCallRotation on IncidentManagementOncallRotation { id name startsAt + endsAt length lengthUnit participants { diff --git a/ee/app/assets/javascripts/security_configuration/components/app.vue b/ee/app/assets/javascripts/security_configuration/components/app.vue index e5109053691abf5c0288357d5a81389167db8a91..96ff21892b5b6e8c661831be66e72305f3686b6a 100644 --- a/ee/app/assets/javascripts/security_configuration/components/app.vue +++ b/ee/app/assets/javascripts/security_configuration/components/app.vue @@ -171,7 +171,13 @@ export default { </gl-sprintf> </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 }"> <div class="gl-text-gray-900">{{ item.name }}</div> <div> diff --git a/ee/spec/features/projects/security/user_views_security_configuration_spec.rb b/ee/spec/features/projects/security/user_views_security_configuration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f850fd731d3e173a1307abe7de0649920b76d14e --- /dev/null +++ b/ee/spec/features/projects/security/user_views_security_configuration_spec.rb @@ -0,0 +1,57 @@ +# 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 diff --git a/ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js b/ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js index fa363ea81be218f266e3f3b0bbf07d6063723fca..3b62df980e5e8b25f9607f54058eba88a132f527 100644 --- a/ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js +++ b/ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js @@ -138,7 +138,8 @@ export const createRotationResponse = { oncallRotation: { id: '44', name: 'Test', - startsAt: '2020-12-17T12:00:00Z', + startsAt: '2020-12-20T12:00:00Z', + endsAt: '2021-03-17T12:00:00Z', length: 5, lengthUnit: 'WEEKS', participants: { @@ -171,7 +172,8 @@ export const createRotationResponseWithErrors = { oncallRotation: { id: '44', name: 'Test', - startsAt: '2020-12-17T12:00:00Z', + startsAt: '2020-12-20T12:00:00Z', + endsAt: '2021-03-17T12:00:00Z', length: 5, lengthUnit: 'WEEKS', participants: { diff --git a/ee/spec/frontend/oncall_schedule/mocks/mock_rotation.json b/ee/spec/frontend/oncall_schedule/mocks/mock_rotation.json index 863835cad4568f66fde25d375d0bca33e676c7ce..00e2152f750703d68e93dfd24459479edc0bf1d5 100644 --- a/ee/spec/frontend/oncall_schedule/mocks/mock_rotation.json +++ b/ee/spec/frontend/oncall_schedule/mocks/mock_rotation.json @@ -2,6 +2,7 @@ "id": "gid://gitlab/IncidentManagement::OncallRotation/2", "name": "Rotation 242", "startsAt": "2021-01-13T10:04:56.333Z", + "endsAt": "2021-03-13T10:04:56.333Z", "length": 1, "lengthUnit": "WEEKS", "participants": { @@ -54,6 +55,7 @@ "id": "gid://gitlab/IncidentManagement::OncallRotation/55", "name": "Rotation 242", "startsAt": "2021-01-13T10:04:56.333Z", + "endsAt": "2021-03-13T10:04:56.333Z", "length": 1, "lengthUnit": "WEEKS", "participants": { @@ -102,6 +104,7 @@ "id": "gid://gitlab/IncidentManagement::OncallRotation/3", "name": "Rotation 244", "startsAt": "2021-01-06T10:04:56.333Z", + "endsAt": "2021-01-10T10:04:56.333Z", "length": 1, "lengthUnit": "WEEKS", "participants": { @@ -150,6 +153,7 @@ "id": "gid://gitlab/IncidentManagement::OncallRotation/5", "name": "Rotation 247", "startsAt": "2021-01-06T10:04:56.333Z", + "endsAt": "2021-01-11T10:04:56.333Z", "length": 1, "lengthUnit": "WEEKS", "participants": { diff --git a/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_form_spec.js b/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_form_spec.js index e32b77a64a9fc162a86207dcb1c729a202d3f956..bf7b5150d6cee08c88d8ff269dd61d3b68a88a8e 100644 --- a/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_form_spec.js +++ b/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_form_spec.js @@ -40,7 +40,7 @@ describe('AddEditRotationForm', () => { date: null, time: 0, }, - endsOn: { + endsAt: { date: null, time: 0, }, @@ -160,7 +160,7 @@ describe('AddEditRotationForm', () => { await wrapper.vm.$nextTick(); const emittedEvent = wrapper.emitted('update-rotation-form'); 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 () => { @@ -168,7 +168,7 @@ describe('AddEditRotationForm', () => { const time = 5; wrapper.setProps({ form: { - endsOn: { + endsAt: { time, }, startsAt: { @@ -221,7 +221,7 @@ describe('AddEditRotationForm', () => { wrapper.setProps({ form: { - endsOn: { + endsAt: { time: 0, }, startsAt: { diff --git a/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_modal_spec.js b/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_modal_spec.js index 7457244c32ca6a6d288b646fb55c54d034618d49..8de9aa5b22a205ed45a2837d02bcf4e0a6829b89 100644 --- a/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_modal_spec.js +++ b/ee/spec/frontend/oncall_schedule/rotations/components/add_edit_rotation_modal_spec.js @@ -1,6 +1,7 @@ import { GlModal, GlAlert } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import AddEditRotationForm from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue'; import AddEditRotationModal, { i18n, } from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_modal.vue'; @@ -129,8 +130,9 @@ describe('AddEditRotationModal', () => { wrapper = null; }); - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findForm = () => wrapper.findComponent(AddEditRotationForm); it('renders rotation modal layout', () => { expect(wrapper.element).toMatchSnapshot(); @@ -155,6 +157,149 @@ describe('AddEditRotationModal', () => { expect(findAlert().exists()).toBe(true); 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', () => { diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index 6ad6123a20e389c876752483c189d83edc504329..e332e5e40fa15495183cc535224eb9907a2070cc 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -5,6 +5,8 @@ module Entities class Project < BasicProjectDetails include ::API::Helpers::RelatedResourcesHelpers + expose :container_registry_url, as: :container_registry_image_prefix, if: -> (_, _) { Gitlab.config.registry.enabled } + expose :_links do expose :self do |project| expose_url(api_v4_projects_path(id: project.id)) diff --git a/lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml b/lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml index 4f86d5001648885a46b11891d2bcac7d802e7788..c856d0a5eeebf732257b5bc1fff5b86f9939b6ce 100644 --- a/lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml +++ b/lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml @@ -42,7 +42,9 @@ 'i_code_review_user_approval_rule_edited', 'i_code_review_user_vs_code_api_request', '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 operator: OR @@ -78,7 +80,9 @@ 'i_code_review_user_approval_rule_deleted', 'i_code_review_user_approval_rule_edited', '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 operator: OR diff --git a/lib/gitlab/usage_data_counters/known_events/code_review_events.yml b/lib/gitlab/usage_data_counters/known_events/code_review_events.yml index d657c5487d79d4ad4dd481a4854c4979d8a9ce72..21613740142ce2edb2671d22c1e6ceb90d0cdd63 100644 --- a/lib/gitlab/usage_data_counters/known_events/code_review_events.yml +++ b/lib/gitlab/usage_data_counters/known_events/code_review_events.yml @@ -164,3 +164,13 @@ category: code_review aggregation: weekly 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 diff --git a/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter.rb index b9856e1f74ad9015c673001738d88900f547f42c..b94caa32bf7f55d738944c008c5d84cdcbb9eac7 100644 --- a/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter.rb +++ b/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter.rb @@ -35,6 +35,8 @@ module MergeRequestActivityUniqueCounter MR_EDIT_MR_TITLE_ACTION = 'i_code_review_edit_mr_title' 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_DISCUSSION_LOCKED_ACTION = 'i_code_review_user_mr_discussion_locked' + MR_DISCUSSION_UNLOCKED_ACTION = 'i_code_review_user_mr_discussion_unlocked' class << self def track_mr_diffs_action(merge_request:) @@ -153,6 +155,14 @@ def track_mr_create_from_issue(user:) track_unique_action_by_user(MR_CREATE_FROM_ISSUE_ACTION, user) 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 def track_unique_action_by_merge_request(action, merge_request) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4c4f25cf7a41aff643153d685459a68be2fe4956..d8e05fd9bafe5b0e00f7b02178ca6bf1e7f8bb28 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21028,6 +21028,9 @@ msgstr "" msgid "OnCallSchedules|Restrict to time intervals" msgstr "" +msgid "OnCallSchedules|Rotation end date/time must come after start date/time" +msgstr "" + msgid "OnCallSchedules|Rotation length" msgstr "" diff --git a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb index a604de4a61fe202c23087b5598131718c38de916..6bc4243088988364a288ba533e5fdf96a8c29ab9 100644 --- a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb @@ -284,4 +284,20 @@ let(:action) { described_class::MR_CREATE_FROM_ISSUE_ACTION } 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 diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index 181fcafd57725828e25e53c37f5608c5786cdf6c..104918810f8a81858568e6a5c95d4b5a21e766fe 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -56,6 +56,7 @@ itself: # project - can_create_merge_request_in - compliance_frameworks - container_expiration_policy + - container_registry_image_prefix - default_branch - empty_repo - forks_count diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 8a68db6ae4b9cb7d4ec650afb43a059fc5a6cf88..19cab51ef3414f7b809339fe65344a7921448d91 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1540,6 +1540,10 @@ end 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) { YAML.load_file(project_attributes_file) } @@ -1569,7 +1573,7 @@ keys end - it 'returns a project by id' do + it 'returns a project by id', :aggregate_failures do project project_member group = create(:group) @@ -1587,6 +1591,7 @@ expect(json_response['ssh_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['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}") expect(json_response['owner']).to be_a Hash expect(json_response['name']).to eq(project.name) expect(json_response['path']).to be_present @@ -1644,9 +1649,10 @@ def failure_message(diff) before do project project_member + stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000') end - it 'returns a project by id' do + it 'returns a project by id', :aggregate_failures do group = create(:group) link = create(:project_group_link, project: project, group: group) @@ -1662,6 +1668,7 @@ def failure_message(diff) expect(json_response['ssh_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['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}") expect(json_response['owner']).to be_a Hash expect(json_response['name']).to eq(project.name) expect(json_response['path']).to be_present diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index a6bd8416e58414a002ad758aa6e15926c419e269..e9ec3bccda3fd1a0a71754fe641b5d2d57042f34 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -48,6 +48,8 @@ def update_merge_request(opts) end context 'valid params' do + let(:locked) { true } + let(:opts) do { title: 'New title', @@ -58,7 +60,7 @@ def update_merge_request(opts) label_ids: [label.id], target_branch: 'target', force_remove_source_branch: '1', - discussion_locked: true + discussion_locked: locked } end @@ -117,6 +119,56 @@ def update_merge_request(opts) MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request) 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 context 'updating milestone' do