diff options
59 files changed, 1124 insertions, 139 deletions
diff --git a/.gitignore b/.gitignore index 3120c1c1bdc..a2441d61bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ jsdoc/ webpack-dev-server.json /.nvimrc .solargraph.yml +apollo.config.js diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index bb9f092a9ae..c08b4fb2f63 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -18,10 +18,15 @@ import query from '../graphql/queries/details.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ALERTS_SEVERITY_LABELS } from '../constants'; +import { + ALERTS_SEVERITY_LABELS, + trackAlertsDetailsViewsOptions, + trackAlertStatusUpdateOptions, +} from '../constants'; import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; export default { statuses: { @@ -108,6 +113,9 @@ export default { return this.errored && !this.isErrorDismissed; }, }, + mounted() { + this.trackPageViews(); + }, methods: { dismissError() { this.isErrorDismissed = true; @@ -122,6 +130,9 @@ export default { projectPath: this.projectPath, }, }) + .then(() => { + this.trackStatusUpdate(status); + }) .catch(() => { createFlash( s__( @@ -157,6 +168,14 @@ export default { issuePath(issueId) { return joinPaths(this.projectIssuesPath, issueId); }, + trackPageViews() { + const { category, action } = trackAlertsDetailsViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, }, }; </script> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue index a1a71e592f6..74ce76739a2 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -19,13 +19,20 @@ import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; -import { ALERTS_STATUS, ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS } from '../constants'; +import { + ALERTS_STATUS, + ALERTS_STATUS_TABS, + ALERTS_SEVERITY_LABELS, + trackAlertListViewsOptions, + trackAlertStatusUpdateOptions, +} from '../constants'; import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; import { capitalizeFirstCharacter, convertToSnakeCase } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; const tdClass = 'table-col d-flex d-md-table-cell align-items-center'; const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-gray-100 hover-bg-blue-50 hover-gl-cursor-pointer hover-gl-border-b-solid hover-gl-border-blue-200'; + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200'; const findDefaultSortColumn = () => document.querySelector('.js-started-at'); export default { @@ -182,6 +189,7 @@ export default { }, mounted() { findDefaultSortColumn().ariaSort = 'ascending'; + this.trackPageViews(); }, methods: { filterAlertsByStatus(tabIndex) { @@ -208,6 +216,7 @@ export default { }, }) .then(() => { + this.trackStatusUpdate(status); this.$apollo.queries.alerts.refetch(); this.$apollo.queries.alertsCount.refetch(); }) @@ -222,6 +231,14 @@ export default { navigateToAlertDetails({ iid }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details')); }, + trackPageViews() { + const { category, action } = trackAlertListViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, }, }; </script> diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index 9df01d9d0b5..a8f5d6cfe30 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -44,3 +44,30 @@ export const ALERTS_STATUS_TABS = [ filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED], }, ]; + +/* eslint-disable @gitlab/require-i18n-strings */ + +/** + * Tracks snowplow event when user views alerts list + */ +export const trackAlertListViewsOptions = { + category: 'Alert Management', + action: 'view_alerts_list', +}; + +/** + * Tracks snowplow event when user views alert details + */ +export const trackAlertsDetailsViewsOptions = { + category: 'Alert Management', + action: 'view_alert_details', +}; + +/** + * Tracks snowplow event when alert status is updated + */ +export const trackAlertStatusUpdateOptions = { + category: 'Alert Management', + action: 'update_alert_status', + label: 'Status', +}; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 878f49cc6be..02fd0334403 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -60,13 +60,11 @@ class ListIssue { } removeAssignee(removeAssignee) { - if (removeAssignee) { - this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); - } + boardsStore.removeIssueAssignee(this, removeAssignee); } removeAllAssignees() { - this.assignees = []; + boardsStore.removeAllIssueAssignees(this); } addMilestone(milestone) { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index fdbd7e89bfb..feef405d52e 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -682,10 +682,20 @@ const boardsStore = { ...this.multiSelect.list.slice(index + 1), ]; }, + removeIssueAssignee(issue, removeAssignee) { + if (removeAssignee) { + issue.assignees = issue.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + }, clearMultiSelect() { this.multiSelect.list = []; }, + + removeAllIssueAssignees(issue) { + issue.assignees = []; + }, + refreshIssueData(issue, obj) { issue.id = obj.id; issue.iid = obj.iid; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 148edfe3a51..da079877c72 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -20,8 +20,13 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import Stacktrace from './stacktrace.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { trackClickErrorLinkToSentryOptions } from '../utils'; import { severityLevel, severityLevelVariant, errorStatus } from './constants'; +import Tracking from '~/tracking'; +import { + trackClickErrorLinkToSentryOptions, + trackErrorDetailsViewsOptions, + trackErrorStatusUpdateOptions, +} from '../utils'; import query from '../queries/details.query.graphql'; @@ -172,6 +177,7 @@ export default { }, }, mounted() { + this.trackPageViews(); this.startPollingStacktrace(this.issueStackTracePath); this.errorPollTimeout = Date.now() + SENTRY_TIMEOUT; this.$apollo.queries.error.setOptions({ @@ -194,7 +200,10 @@ export default { onIgnoreStatusUpdate() { const status = this.errorStatus === errorStatus.IGNORED ? errorStatus.UNRESOLVED : errorStatus.IGNORED; - this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }); + // eslint-disable-next-line promise/catch-or-return + this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }).then(() => { + this.trackStatusUpdate(status); + }); }, onResolveStatusUpdate() { const status = @@ -206,6 +215,7 @@ export default { if (this.closedIssueId) { this.isAlertVisible = true; } + this.trackStatusUpdate(status); }); }, onNoApolloResult() { @@ -218,6 +228,14 @@ export default { formatDate(date) { return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; }, + trackPageViews() { + const { category, action } = trackErrorDetailsViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackErrorStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, }, }; </script> @@ -259,7 +277,7 @@ export default { <div class="d-inline-flex bv-d-sm-down-none"> <gl-deprecated-button :loading="updatingIgnoreStatus" - data-qa-selector="update_ignore_status_button" + data-testid="update-ignore-status-btn" @click="onIgnoreStatusUpdate" > {{ ignoreBtnLabel }} @@ -267,7 +285,7 @@ export default { <gl-deprecated-button class="btn-outline-info ml-2" :loading="updatingResolveStatus" - data-qa-selector="update_resolve_status_button" + data-testid="update-resolve-status-btn" @click="onResolveStatusUpdate" > {{ resolveBtnLabel }} @@ -275,7 +293,7 @@ export default { <gl-deprecated-button v-if="error.gitlabIssuePath" class="ml-2" - data-qa-selector="view_issue_button" + data-testid="view_issue_button" :href="error.gitlabIssuePath" variant="success" > @@ -375,6 +393,7 @@ export default { v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)" :href="error.externalUrl" target="_blank" + data-testid="external-url-link" > <span class="text-truncate">{{ error.externalUrl }}</span> <icon name="external-link" class="ml-1 flex-shrink-0" /> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 45432e8ebd8..111b5ad60a5 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -19,6 +19,8 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; import { isEmpty } from 'lodash'; import ErrorTrackingActions from './error_tracking_actions.vue'; +import Tracking from '~/tracking'; +import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils'; export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center'; @@ -150,6 +152,9 @@ export default { this.startPolling(); } }, + mounted() { + this.trackPageViews(); + }, methods: { ...mapActions('list', [ 'startPolling', @@ -197,13 +202,25 @@ export default { this.filterValue = label; return this.filterByStatus(status); }, - updateIssueStatus({ errorId, status }) { + updateErrosStatus({ errorId, status }) { + // eslint-disable-next-line promise/catch-or-return this.updateStatus({ endpoint: this.getIssueUpdatePath(errorId), status, + }).then(() => { + this.trackStatusUpdate(status); }); + this.removeIgnoredResolvedErrors(errorId); }, + trackPageViews() { + const { category, action } = trackErrorListViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackErrorStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, }, }; </script> @@ -359,7 +376,7 @@ export default { </div> </template> <template #cell(status)="errors"> - <error-tracking-actions :error="errors.item" @update-issue-status="updateIssueStatus" /> + <error-tracking-actions :error="errors.item" @update-issue-status="updateErrosStatus" /> </template> <template #empty> {{ __('No errors to display.') }} diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js index d1cd70a72fa..e519b8ebfe5 100644 --- a/app/assets/javascripts/error_tracking/utils.js +++ b/app/assets/javascripts/error_tracking/utils.js @@ -1,4 +1,4 @@ -/* eslint-disable @gitlab/require-i18n-strings, import/prefer-default-export */ +/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when User clicks on error link to Sentry @@ -10,3 +10,28 @@ export const trackClickErrorLinkToSentryOptions = url => ({ label: 'Error Link', property: url, }); + +/** + * Tracks snowplow event when user views error list + */ +export const trackErrorListViewsOptions = { + category: 'Error Tracking', + action: 'view_errors_list', +}; + +/** + * Tracks snowplow event when user views error details + */ +export const trackErrorDetailsViewsOptions = { + category: 'Error Tracking', + action: 'view_error_details', +}; + +/** + * Tracks snowplow event when error status is updated + */ +export const trackErrorStatusUpdateOptions = { + category: 'Error Tracking', + action: 'update_error_status', + label: 'Status', +}; diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index eb514b5c070..a8589b50899 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -110,8 +110,8 @@ export default { <gl-new-dropdown :text="dropdownText" :disabled="hasSearchParam" - toggle-class="gl-py-3" - class="gl-dropdown w-100 mt-2 mt-sm-0" + toggle-class="gl-py-3 gl-border-0" + class="w-100 mt-2 mt-sm-0" > <gl-new-dropdown-header> {{ __('Search by author') }} diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/pages/storage_quota.scss new file mode 100644 index 00000000000..97ae4f0ade4 --- /dev/null +++ b/app/assets/stylesheets/pages/storage_quota.scss @@ -0,0 +1,23 @@ +.storage-type-usage { + &:first-child { + @include gl-rounded-top-left-base; + @include gl-rounded-bottom-left-base; + } + + &:last-child { + @include gl-rounded-top-right-base; + @include gl-rounded-bottom-right-base; + } + + &:not(:first-child) { + @include gl-border-l-1; + @include gl-border-l-solid; + @include gl-border-white; + } + + &:not(:last-child) { + @include gl-border-r-1; + @include gl-border-r-solid; + @include gl-border-white; + } +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 2b51bdd825f..803eac52317 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -87,21 +87,5 @@ } } -.gl-shim-h-2 { - height: px-to-rem(4px); -} - -.gl-shim-w-5 { - width: px-to-rem(16px); -} - -.gl-shim-pb-3 { - padding-bottom: 8px; -} - -.gl-shim-pt-5 { - padding-top: 16px; -} - .gl-text-purple { color: $purple; } .gl-bg-purple-light { background-color: $purple-light; } diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index d4b0d3b2674..d3dfb1813e4 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -13,9 +13,7 @@ module NotesActions end def index - current_fetched_at = Time.current.to_i - - notes_json = { notes: [], last_fetched_at: current_fetched_at } + notes_json = { notes: [], last_fetched_at: Time.current.to_i } notes = notes_finder .execute @@ -24,7 +22,7 @@ module NotesActions if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] notes = ResourceEvents::MergeIntoNotesService - .new(noteable, current_user, last_fetched_at: current_fetched_at) + .new(noteable, current_user, last_fetched_at: last_fetched_at) .execute(notes) end diff --git a/app/finders/resource_milestone_event_finder.rb b/app/finders/resource_milestone_event_finder.rb new file mode 100644 index 00000000000..7af34f0a4bc --- /dev/null +++ b/app/finders/resource_milestone_event_finder.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ResourceMilestoneEventFinder + include FinderMethods + + MAX_PER_PAGE = 100 + + attr_reader :params, :current_user, :eventable + + def initialize(current_user, eventable, params = {}) + @current_user = current_user + @eventable = eventable + @params = params + end + + def execute + Kaminari.paginate_array(visible_events) + end + + private + + def visible_events + @visible_events ||= visible_to_user(events) + end + + def events + @events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page) + end + + def visible_to_user(events) + events.select { |event| visible_for_user?(event) } + end + + def visible_for_user?(event) + milestone = event_milestones[event.milestone_id] + return if milestone.blank? + + parent = milestone.parent + parent_availabilities[key_for_parent(parent)] + end + + def parent_availabilities + @parent_availabilities ||= relevant_parents.to_h do |parent| + [key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)] + end + end + + def key_for_parent(parent) + "#{parent.class.name}_#{parent.id}" + end + + def event_milestones + @milestones ||= events.map(&:milestone).uniq.to_h do |milestone| + [milestone.id, milestone] + end + end + + def relevant_parents + @relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent } + end + + def per_page + [params[:per_page], MAX_PER_PAGE].compact.min + end + + def page + params[:page] || 1 + end +end diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 557f3a63280..89ace3f7ede 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -13,6 +13,11 @@ module Ci message: "(%{value}) has already been taken" } + validates :encrypted_value, length: { + maximum: 1024, + too_long: 'The encrypted value of the provided variable exceeds %{count} bytes. Variables over 700 characters risk exceeding the limit.' + } + scope :unprotected, -> { where(protected: false) } after_commit { self.class.invalidate_memory_cache(:ci_instance_variable_data) } diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 2b072b22454..f37525e56e4 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -401,7 +401,7 @@ module Ci # The `Ci::Stage` contains all up-to date data # as atomic processing updates all data in-bulk stages - elsif Feature.enabled?(:ci_pipeline_persisted_stages, default_enabled: true) && complete? + elsif complete? # The `Ci::Stage` contains up-to date data only for `completed` pipelines # this is due to asynchronous processing of pipeline, and stages possibly # not updated inline with processing of pipeline diff --git a/app/models/event.rb b/app/models/event.rb index 12b85697690..25d016291a7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -21,6 +21,7 @@ class Event < ApplicationRecord LEFT = 9 # User left project DESTROYED = 10 EXPIRED = 11 # User left project due to expiry + APPROVED = 12 ACTIONS = HashWithIndifferentAccess.new( created: CREATED, @@ -33,7 +34,8 @@ class Event < ApplicationRecord joined: JOINED, left: LEFT, destroyed: DESTROYED, - expired: EXPIRED + expired: EXPIRED, + approved: APPROVED ).freeze WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index 039f26d8e3f..36068cf508b 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -9,6 +9,8 @@ class ResourceMilestoneEvent < ResourceEvent validate :exactly_one_issuable + scope :include_relations, -> { includes(:user, milestone: [:project, :group]) } + enum action: { add: 1, remove: 2 @@ -26,4 +28,12 @@ class ResourceMilestoneEvent < ResourceEvent def milestone_title milestone&.title end + + def milestone_parent + milestone&.parent + end + + def issuable + issue || merge_request + end end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index 39a6889fc84..abac0ffc5d9 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -22,7 +22,7 @@ module Groups save! ensure - cleanup + remove_base_tmp_dir end private @@ -81,8 +81,8 @@ module Groups Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) end - def cleanup - FileUtils.rm_rf(shared.archive_path) if shared&.archive_path + def remove_base_tmp_dir + FileUtils.rm_rf(shared.base_path) if shared&.base_path end def notify_error! diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index dcd78210801..bd611d55847 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -26,6 +26,7 @@ module Groups end ensure + remove_base_tmp_dir remove_import_file end @@ -102,6 +103,10 @@ module Groups raise Gitlab::ImportExport::Error.new(@shared.errors.to_sentence) end + + def remove_base_tmp_dir + FileUtils.rm_rf(@shared.base_path) + end end end end diff --git a/changelogs/unreleased/210550-conan-export-tgz.yml b/changelogs/unreleased/210550-conan-export-tgz.yml new file mode 100644 index 00000000000..bffd0b8b644 --- /dev/null +++ b/changelogs/unreleased/210550-conan-export-tgz.yml @@ -0,0 +1,5 @@ +--- +title: Conan package registry support for the conan_export.tgz file +merge_request: 32866 +author: +type: fixed diff --git a/changelogs/unreleased/217680-health-metrics-instrumentation.yml b/changelogs/unreleased/217680-health-metrics-instrumentation.yml new file mode 100644 index 00000000000..77ae44aaf38 --- /dev/null +++ b/changelogs/unreleased/217680-health-metrics-instrumentation.yml @@ -0,0 +1,5 @@ +--- +title: Monitor:Health metrics instrumenation +merge_request: 32846 +author: +type: added diff --git a/changelogs/unreleased/217743-match-commits-filter-author-button-to-spec.yml b/changelogs/unreleased/217743-match-commits-filter-author-button-to-spec.yml new file mode 100644 index 00000000000..c90ceb3190b --- /dev/null +++ b/changelogs/unreleased/217743-match-commits-filter-author-button-to-spec.yml @@ -0,0 +1,5 @@ +--- +title: Make commits author button confirm to Pajamas specs +merge_request: 32821 +author: +type: fixed diff --git a/changelogs/unreleased/217936-validate-the-size-of-the-value-for-instance-level-variables.yml b/changelogs/unreleased/217936-validate-the-size-of-the-value-for-instance-level-variables.yml new file mode 100644 index 00000000000..b62aa0a8c20 --- /dev/null +++ b/changelogs/unreleased/217936-validate-the-size-of-the-value-for-instance-level-variables.yml @@ -0,0 +1,5 @@ +--- +title: Add value length validations for instance level variable +merge_request: 32303 +author: +type: fixed diff --git a/changelogs/unreleased/218757-fix-polling-for-events.yml b/changelogs/unreleased/218757-fix-polling-for-events.yml new file mode 100644 index 00000000000..10accb9ba4d --- /dev/null +++ b/changelogs/unreleased/218757-fix-polling-for-events.yml @@ -0,0 +1,5 @@ +--- +title: Fix polling for resource events +merge_request: 33025 +author: +type: fixed diff --git a/changelogs/unreleased/Remove-removeAllAssignees-logic-from-issue-model.yml b/changelogs/unreleased/Remove-removeAllAssignees-logic-from-issue-model.yml new file mode 100644 index 00000000000..df0488a3882 --- /dev/null +++ b/changelogs/unreleased/Remove-removeAllAssignees-logic-from-issue-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove removeAllAssignees logic from issue model +merge_request: 32247 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/Remove-removeAssignee-logic-from-issue-model.yml b/changelogs/unreleased/Remove-removeAssignee-logic-from-issue-model.yml new file mode 100644 index 00000000000..257f95de712 --- /dev/null +++ b/changelogs/unreleased/Remove-removeAssignee-logic-from-issue-model.yml @@ -0,0 +1,5 @@ +--- +title: Remove removeAssignee logic from issue model +merge_request: 32248 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/ab-services-partial-indexes.yml b/changelogs/unreleased/ab-services-partial-indexes.yml new file mode 100644 index 00000000000..15dad096f7b --- /dev/null +++ b/changelogs/unreleased/ab-services-partial-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Adjust condition for partial indexes on services table +merge_request: 33044 +author: +type: performance diff --git a/changelogs/unreleased/add-api-endpoint-for-resource-milestone-events-pd.yml b/changelogs/unreleased/add-api-endpoint-for-resource-milestone-events-pd.yml new file mode 100644 index 00000000000..8f5ef68ba5b --- /dev/null +++ b/changelogs/unreleased/add-api-endpoint-for-resource-milestone-events-pd.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint for resource milestone events +merge_request: 31720 +author: +type: added diff --git a/changelogs/unreleased/georgekoltsov-import-export-tmp-folder-cleanup.yml b/changelogs/unreleased/georgekoltsov-import-export-tmp-folder-cleanup.yml new file mode 100644 index 00000000000..d79f751a48f --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-import-export-tmp-folder-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Clean up shared/tmp folder after Import/Export +merge_request: 32326 +author: +type: fixed diff --git a/db/migrate/20200526120714_change_partial_indexes_on_services.rb b/db/migrate/20200526120714_change_partial_indexes_on_services.rb new file mode 100644 index 00000000000..a4d58cda105 --- /dev/null +++ b/db/migrate/20200526120714_change_partial_indexes_on_services.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ChangePartialIndexesOnServices < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :services, [:type, :instance], unique: true, where: 'instance = true', name: 'index_services_on_type_and_instance_partial' + remove_concurrent_index_by_name :services, 'index_services_on_type_and_instance' + + add_concurrent_index :services, [:type, :template], unique: true, where: 'template = true', name: 'index_services_on_type_and_template_partial' + remove_concurrent_index_by_name :services, 'index_services_on_type_and_template' + end + + def down + add_concurrent_index :services, [:type, :instance], unique: true, where: 'instance IS TRUE', name: 'index_services_on_type_and_instance' + remove_concurrent_index_by_name :services, 'index_services_on_type_and_instance_partial' + + add_concurrent_index :services, [:type, :template], unique: true, where: 'template IS TRUE', name: 'index_services_on_type_and_template' + remove_concurrent_index_by_name :services, 'index_services_on_type_and_template_partial' + end +end diff --git a/db/structure.sql b/db/structure.sql index ef61b7de4dd..09ee7544c72 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10647,9 +10647,9 @@ CREATE INDEX index_services_on_type ON public.services USING btree (type); CREATE INDEX index_services_on_type_and_id_and_template_when_active ON public.services USING btree (type, id, template) WHERE (active = true); -CREATE UNIQUE INDEX index_services_on_type_and_instance ON public.services USING btree (type, instance) WHERE (instance IS TRUE); +CREATE UNIQUE INDEX index_services_on_type_and_instance_partial ON public.services USING btree (type, instance) WHERE (instance = true); -CREATE UNIQUE INDEX index_services_on_type_and_template ON public.services USING btree (type, template) WHERE (template IS TRUE); +CREATE UNIQUE INDEX index_services_on_type_and_template_partial ON public.services USING btree (type, template) WHERE (template = true); CREATE UNIQUE INDEX index_shards_on_name ON public.shards USING btree (name); @@ -13934,5 +13934,6 @@ COPY "schema_migrations" (version) FROM STDIN; 20200519171058 20200525114553 20200525121014 +20200526120714 \. diff --git a/doc/api/instance_level_ci_variables.md b/doc/api/instance_level_ci_variables.md index d0871fdf4a7..3acd1652e1c 100644 --- a/doc/api/instance_level_ci_variables.md +++ b/doc/api/instance_level_ci_variables.md @@ -77,7 +77,7 @@ POST /admin/ci/variables | Attribute | Type | required | Description | |-----------------|---------|----------|-----------------------| | `key` | string | yes | The `key` of a variable. Max 255 characters, only `A-Z`, `a-z`, `0-9`, and `_` are allowed. | -| `value` | string | yes | The `value` of a variable. | +| `value` | string | yes | The `value` of a variable. Around 700 characters allowed. | | `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file`. | | `protected` | boolean | no | Whether the variable is protected. | | `masked` | boolean | no | Whether the variable is masked. | diff --git a/doc/api/resource_milestone_events.md b/doc/api/resource_milestone_events.md new file mode 100644 index 00000000000..695687ada6d --- /dev/null +++ b/doc/api/resource_milestone_events.md @@ -0,0 +1,224 @@ +--- +stage: Plan +group: Project Management +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + +# Resource milestone events API + +Resource milestone events keep track of what happens to GitLab [issues](../user/project/issues/), +[merge requests](../user/project/merge_requests/), and [epics](../user/group/epics/). + +Use them to track which milestone was added or removed, who did it, and when it happened. + +## Issues + +### List project issue milestone events + +Gets a list of all milestone events for a single issue. + +```plaintext +GET /projects/:id/issues/:issue_iid/resource_milestone_events +``` + +| Attribute | Type | Required | Description | +| ----------- | -------------- | -------- | ------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `issue_iid` | integer | yes | The IID of an issue | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/resource_milestone_events" +``` + +Example response: + +```json +[ + { + "id": 142, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-20T13:38:20.077Z", + "resource_type": "Issue", + "resource_id": 253, + "milestone": { + "id": 61, + "iid": 9, + "project_id": 7, + "title": "v1.2", + "description": "Ipsum Lorem", + "state": "active", + "created_at": "2020-01-27T05:07:12.573Z", + "updated_at": "2020-01-27T05:07:12.573Z", + "due_date": null, + "start_date": null, + "web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9" + }, + "action": "add" + }, + { + "id": 143, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-21T14:38:20.077Z", + "resource_type": "Issue", + "resource_id": 253, + "milestone": { + "id": 61, + "iid": 9, + "project_id": 7, + "title": "v1.2", + "description": "Ipsum Lorem", + "state": "active", + "created_at": "2020-01-27T05:07:12.573Z", + "updated_at": "2020-01-27T05:07:12.573Z", + "due_date": null, + "start_date": null, + "web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9" + }, + "action": "remove" + } +] +``` + +### Get single issue milestone event + +Returns a single milestone event for a specific project issue + +```plaintext +GET /projects/:id/issues/:issue_iid/resource_milestone_events/:resource_milestone_event_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| ----------------------------- | -------------- | -------- | ------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path](README.md#namespaced-path-encoding) of the project | +| `issue_iid` | integer | yes | The IID of an issue | +| `resource_milestone_event_id` | integer | yes | The ID of a milestone event | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/resource_milestone_events/1" +``` + +## Merge requests + +### List project merge request milestone events + +Gets a list of all milestone events for a single merge request. + +```plaintext +GET /projects/:id/merge_requests/:merge_request_iid/resource_milestone_events +``` + +| Attribute | Type | Required | Description | +| ------------------- | -------------- | -------- | ------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path](README.md#namespaced-path-encoding) of the project | +| `merge_request_iid` | integer | yes | The IID of a merge request | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/resource_milestone_events" +``` + +Example response: + +```json +[ + { + "id": 142, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-20T13:38:20.077Z", + "resource_type": "MergeRequest", + "resource_id": 142, + "milestone": { + "id": 61, + "iid": 9, + "project_id": 7, + "title": "v1.2", + "description": "Ipsum Lorem", + "state": "active", + "created_at": "2020-01-27T05:07:12.573Z", + "updated_at": "2020-01-27T05:07:12.573Z", + "due_date": null, + "start_date": null, + "web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9" + }, + "action": "add" + }, + { + "id": 143, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-21T14:38:20.077Z", + "resource_type": "MergeRequest", + "resource_id": 142, + "milestone": { + "id": 61, + "iid": 9, + "project_id": 7, + "title": "v1.2", + "description": "Ipsum Lorem", + "state": "active", + "created_at": "2020-01-27T05:07:12.573Z", + "updated_at": "2020-01-27T05:07:12.573Z", + "due_date": null, + "start_date": null, + "web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9" + }, + "action": "remove" + } +] +``` + +### Get single merge request milestone event + +Returns a single milestone event for a specific project merge request + +```plaintext +GET /projects/:id/merge_requests/:merge_request_iid/resource_milestone_events/:resource_milestone_event_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| ----------------------------- | -------------- | -------- | ------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `merge_request_iid` | integer | yes | The IID of a merge request | +| `resource_milestone_event_id` | integer | yes | The ID of a milestone event | + +Example request: + +```shell +curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/resource_milestone_events/120" +``` diff --git a/lib/api/api.rb b/lib/api/api.rb index b8135539cda..cdfdc7f92dd 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -170,6 +170,7 @@ module API mount ::API::Notes mount ::API::Discussions mount ::API::ResourceLabelEvents + mount ::API::ResourceMilestoneEvents mount ::API::NotificationSettings mount ::API::Pages mount ::API::PagesDomains diff --git a/lib/api/entities/resource_milestone_event.rb b/lib/api/entities/resource_milestone_event.rb new file mode 100644 index 00000000000..26dc6620cbe --- /dev/null +++ b/lib/api/entities/resource_milestone_event.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module API + module Entities + class ResourceMilestoneEvent < Grape::Entity + expose :id + expose :user, using: Entities::UserBasic + expose :created_at + expose :resource_type do |event, _options| + event.issuable.class.name + end + expose :resource_id do |event, _options| + event.issuable.id + end + expose :milestone, using: Entities::Milestone + expose :action + expose :state + end + end +end diff --git a/lib/api/resource_milestone_events.rb b/lib/api/resource_milestone_events.rb new file mode 100644 index 00000000000..30ff5a9b4be --- /dev/null +++ b/lib/api/resource_milestone_events.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module API + class ResourceMilestoneEvents < Grape::API + include PaginationParams + helpers ::API::Helpers::NotesHelpers + + before { authenticate! } + + [Issue, MergeRequest].each do |eventable_type| + parent_type = eventable_type.parent_class.to_s.underscore + eventables_str = eventable_type.to_s.underscore.pluralize + + params do + requires :id, type: String, desc: "The ID of a #{parent_type}" + end + resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc "Get a list of #{eventable_type.to_s.downcase} resource milestone events" do + success Entities::ResourceMilestoneEvent + end + params do + requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable' + use :pagination + end + + get ":id/#{eventables_str}/:eventable_id/resource_milestone_events" do + eventable = find_noteable(eventable_type, params[:eventable_id]) + + opts = { page: params[:page], per_page: params[:per_page] } + events = ResourceMilestoneEventFinder.new(current_user, eventable, opts).execute + + present paginate(events), with: Entities::ResourceMilestoneEvent + end + + desc "Get a single #{eventable_type.to_s.downcase} resource milestone event" do + success Entities::ResourceMilestoneEvent + end + params do + requires :event_id, type: String, desc: 'The ID of a resource milestone event' + requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable' + end + get ":id/#{eventables_str}/:eventable_id/resource_milestone_events/:event_id" do + eventable = find_noteable(eventable_type, params[:eventable_id]) + + event = eventable.resource_milestone_events.find(params[:event_id]) + + not_found!('ResourceMilestoneEvent') unless can?(current_user, :read_milestone, event.milestone_parent) + + present event, with: Entities::ResourceMilestoneEvent + end + end + end + end +end diff --git a/lib/gitlab/import_export/importer.rb b/lib/gitlab/import_export/importer.rb index b1219384732..d5816100023 100644 --- a/lib/gitlab/import_export/importer.rb +++ b/lib/gitlab/import_export/importer.rb @@ -26,6 +26,7 @@ module Gitlab rescue => e raise Projects::ImportService::Error.new(e.message) ensure + remove_base_tmp_dir remove_import_file end @@ -148,6 +149,10 @@ module Gitlab ::Project.find_by_full_path("#{project.namespace.full_path}/#{original_path}") end end + + def remove_base_tmp_dir + FileUtils.rm_rf(@shared.base_path) + end end end end diff --git a/lib/gitlab/import_export/saver.rb b/lib/gitlab/import_export/saver.rb index bd69673ecdf..e4724659eff 100644 --- a/lib/gitlab/import_export/saver.rb +++ b/lib/gitlab/import_export/saver.rb @@ -16,8 +16,6 @@ module Gitlab def save if compress_and_save - remove_export_path - Gitlab::Export::Logger.info( message: 'Export archive saved', exportable_class: @exportable.class.to_s, @@ -33,8 +31,7 @@ module Gitlab @shared.error(e) false ensure - remove_archive - remove_export_path + remove_base_tmp_dir end private @@ -43,12 +40,8 @@ module Gitlab tar_czf(archive: archive_file, dir: @shared.export_path) end - def remove_export_path - FileUtils.rm_rf(@shared.export_path) - end - - def remove_archive - FileUtils.rm_rf(@shared.archive_path) + def remove_base_tmp_dir + FileUtils.rm_rf(@shared.base_path) end def archive_file diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1ce0888b764..c4e5a217833 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23566,9 +23566,6 @@ msgstr "" msgid "Usage ping is not enabled" msgstr "" -msgid "Usage quotas help link" -msgstr "" - msgid "Usage statistics" msgstr "" @@ -23578,12 +23575,18 @@ msgstr "" msgid "UsageQuota|Artifacts" msgstr "" +msgid "UsageQuota|Build Artifacts" +msgstr "" + msgid "UsageQuota|Buy additional minutes" msgstr "" msgid "UsageQuota|Current period usage" msgstr "" +msgid "UsageQuota|LFS Objects" +msgstr "" + msgid "UsageQuota|LFS Storage" msgstr "" @@ -23593,12 +23596,18 @@ msgstr "" msgid "UsageQuota|Pipelines" msgstr "" +msgid "UsageQuota|Repositories" +msgstr "" + msgid "UsageQuota|Repository" msgstr "" msgid "UsageQuota|Storage" msgstr "" +msgid "UsageQuota|Storage usage:" +msgstr "" + msgid "UsageQuota|This namespace has no projects which use shared runners" msgstr "" @@ -23617,12 +23626,18 @@ msgstr "" msgid "UsageQuota|Usage of resources across your projects" msgstr "" +msgid "UsageQuota|Usage quotas help link" +msgstr "" + msgid "UsageQuota|Usage since" msgstr "" msgid "UsageQuota|Wiki" msgstr "" +msgid "UsageQuota|Wikis" +msgstr "" + msgid "Use %{code_start}::%{code_end} to create a %{link_start}scoped label set%{link_end} (eg. %{code_start}priority::1%{code_end})" msgstr "" diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 39594ff287d..f883c02af65 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -37,7 +37,7 @@ describe Projects::NotesController do project.add_developer(user) end - it 'passes last_fetched_at from headers to NotesFinder' do + it 'passes last_fetched_at from headers to NotesFinder and MergeIntoNotesService' do last_fetched_at = 3.hours.ago.to_i request.headers['X-Last-Fetched-At'] = last_fetched_at @@ -46,6 +46,10 @@ describe Projects::NotesController do .with(anything, hash_including(last_fetched_at: last_fetched_at)) .and_call_original + expect(ResourceEvents::MergeIntoNotesService).to receive(:new) + .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) + .and_call_original + get :index, params: request_params end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 8db7a571c62..00d69860665 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -26,10 +26,6 @@ describe Projects::PipelinesController do context 'when using persisted stages', :request_store do render_views - before do - stub_feature_flags(ci_pipeline_persisted_stages: true) - end - it 'returns serialized pipelines' do expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original @@ -66,46 +62,6 @@ describe Projects::PipelinesController do end end - context 'when using legacy stages', :request_store do - before do - stub_feature_flags(ci_pipeline_persisted_stages: false) - end - - it 'returns JSON with serialized pipelines' do - get_pipelines_index_json - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('pipeline') - - expect(json_response).to include('pipelines') - expect(json_response['pipelines'].count).to eq 6 - expect(json_response['count']['all']).to eq '6' - expect(json_response['count']['running']).to eq '2' - expect(json_response['count']['pending']).to eq '1' - expect(json_response['count']['finished']).to eq '3' - - json_response.dig('pipelines', 0, 'details', 'stages').tap do |stages| - expect(stages.count).to eq 3 - end - end - - it 'does not execute N+1 queries' do - get_pipelines_index_json - - control_count = ActiveRecord::QueryRecorder.new do - get_pipelines_index_json - end.count - - create_all_pipeline_types - - # There appears to be one extra query for Pipelines#has_warnings? for some reason - expect { get_pipelines_index_json }.not_to exceed_query_limit(control_count + 1) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['pipelines'].count).to eq 12 - end - end - it 'does not include coverage data for the pipelines' do get_pipelines_index_json diff --git a/spec/finders/resource_milestone_event_finder_spec.rb b/spec/finders/resource_milestone_event_finder_spec.rb new file mode 100644 index 00000000000..fa7fda37849 --- /dev/null +++ b/spec/finders/resource_milestone_event_finder_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ResourceMilestoneEventFinder do + let_it_be(:user) { create(:user) } + let_it_be(:issue_project) { create(:project) } + let_it_be(:issue) { create(:issue, project: issue_project) } + + describe '#execute' do + subject { described_class.new(user, issue).execute } + + it 'returns events with milestones accessible by user' do + milestone = create(:milestone, project: issue_project) + event = create_event(milestone) + issue_project.add_guest(user) + + expect(subject).to eq [event] + end + + it 'filters events with public project milestones if issues and MRs are private' do + project = create(:project, :public, :issues_private, :merge_requests_private) + milestone = create(:milestone, project: project) + create_event(milestone) + + expect(subject).to be_empty + end + + it 'filters events with project milestones not accessible by user' do + project = create(:project, :private) + milestone = create(:milestone, project: project) + create_event(milestone) + + expect(subject).to be_empty + end + + it 'filters events with group milestones not accessible by user' do + group = create(:group, :private) + milestone = create(:milestone, group: group) + create_event(milestone) + + expect(subject).to be_empty + end + + it 'paginates results' do + milestone = create(:milestone, project: issue_project) + create_event(milestone) + create_event(milestone) + issue_project.add_guest(user) + + paginated = described_class.new(user, issue, per_page: 1).execute + + expect(subject.count).to eq 2 + expect(paginated.count).to eq 1 + end + + context 'when multiple events share the same milestone' do + it 'avoids N+1 queries' do + issue_project.add_developer(user) + + milestone1 = create(:milestone, project: issue_project) + milestone2 = create(:milestone, project: issue_project) + + control_count = ActiveRecord::QueryRecorder.new { described_class.new(user, issue).execute }.count + expect(control_count).to eq(1) # 1 events query + + create_event(milestone1, :add) + create_event(milestone1, :remove) + create_event(milestone1, :add) + create_event(milestone1, :remove) + create_event(milestone2, :add) + create_event(milestone2, :remove) + + # 1 events + 1 milestones + 1 project + 1 user + 4 ability + expect { described_class.new(user, issue).execute }.not_to exceed_query_limit(control_count + 7) + end + end + + def create_event(milestone, action = :add) + create(:resource_milestone_event, issue: issue, milestone: milestone, action: action) + end + end +end diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js index 64d59884594..b1dff3d8ebb 100644 --- a/spec/frontend/alert_management/components/alert_management_detail_spec.js +++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js @@ -5,6 +5,11 @@ import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql'; import createFlash from '~/flash'; import { joinPaths } from '~/lib/utils/url_utility'; +import { + trackAlertsDetailsViewsOptions, + trackAlertStatusUpdateOptions, +} from '~/alert_management/constants'; +import Tracking from '~/tracking'; import mockAlerts from '../mocks/alerts.json'; @@ -253,7 +258,7 @@ describe('AlertDetails', () => { }); }); - describe('updating the alert status', () => { + describe('Updating the alert status', () => { const mockUpdatedMutationResult = { data: { updateAlertStatus: { @@ -298,4 +303,31 @@ describe('AlertDetails', () => { }); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alert: mockAlert }, + loading: false, + }); + }); + + it('should track alert details page views', () => { + const { category, action } = trackAlertsDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findStatusDropdownItem().vm.$emit('click'); + const status = findStatusDropdownItem().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); + }); + }); + }); }); diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js index 0f18600a736..88dde9f16f7 100644 --- a/spec/frontend/alert_management/components/alert_management_list_spec.js +++ b/spec/frontend/alert_management/components/alert_management_list_spec.js @@ -14,9 +14,14 @@ import { visitUrl } from '~/lib/utils/url_utility'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import createFlash from '~/flash'; import AlertManagementList from '~/alert_management/components/alert_management_list.vue'; -import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants'; +import { + ALERTS_STATUS_TABS, + trackAlertListViewsOptions, + trackAlertStatusUpdateOptions, +} from '~/alert_management/constants'; import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; import mockAlerts from '../mocks/alerts.json'; +import Tracking from '~/tracking'; jest.mock('~/flash'); @@ -94,7 +99,7 @@ describe('AlertManagementList', () => { } }); - describe('alert management feature renders empty state', () => { + describe('Empty state', () => { it('shows empty state', () => { expect(wrapper.find(GlEmptyState).exists()).toBe(true); }); @@ -137,7 +142,7 @@ describe('AlertManagementList', () => { findAlerts() .at(0) .classes(), - ).not.toContain('hover-bg-blue-50'); + ).not.toContain('gl-hover-bg-blue-50'); }); it('error state', () => { @@ -154,7 +159,7 @@ describe('AlertManagementList', () => { findAlerts() .at(0) .classes(), - ).not.toContain('hover-bg-blue-50'); + ).not.toContain('gl-hover-bg-blue-50'); }); it('empty state', () => { @@ -171,7 +176,7 @@ describe('AlertManagementList', () => { findAlerts() .at(0) .classes(), - ).not.toContain('hover-bg-blue-50'); + ).not.toContain('gl-hover-bg-blue-50'); }); it('has data state', () => { @@ -187,7 +192,7 @@ describe('AlertManagementList', () => { findAlerts() .at(0) .classes(), - ).toContain('hover-bg-blue-50'); + ).toContain('gl-hover-bg-blue-50'); }); it('displays status dropdown', () => { @@ -363,4 +368,31 @@ describe('AlertManagementList', () => { }); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, alertsCount }, + loading: false, + }); + }); + + it('should track alert list page views', () => { + const { category, action } = trackAlertListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findFirstStatusOption().vm.$emit('click'); + const status = findFirstStatusOption().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); + }); + }); + }); }); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index adbbc04ce78..03181e322a1 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -18,6 +18,12 @@ import { severityLevelVariant, errorStatus, } from '~/error_tracking/components/constants'; +import Tracking from '~/tracking'; +import { + trackClickErrorLinkToSentryOptions, + trackErrorDetailsViewsOptions, + trackErrorStatusUpdateOptions, +} from '~/error_tracking/utils'; jest.mock('~/flash'); @@ -30,12 +36,19 @@ describe('ErrorDetails', () => { let actions; let getters; let mocks; + const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1'; const findInput = name => { const inputs = wrapper.findAll(GlFormInput).filter(c => c.attributes('name') === name); return inputs.length ? inputs.at(0) : inputs; }; + const findUpdateIgnoreStatusButton = () => + wrapper.find('[data-testid="update-ignore-status-btn"]'); + const findUpdateResolveStatusButton = () => + wrapper.find('[data-testid="update-resolve-status-btn"]'); + const findExternalUrl = () => wrapper.find('[data-testid="external-url-link"]'); + function mountComponent() { wrapper = shallowMount(ErrorDetails, { stubs: { GlDeprecatedButton, GlSprintf }, @@ -57,7 +70,7 @@ describe('ErrorDetails', () => { beforeEach(() => { actions = { startPollingStacktrace: () => {}, - updateIgnoreStatus: jest.fn(), + updateIgnoreStatus: jest.fn().mockResolvedValue({}), updateResolveStatus: jest.fn().mockResolvedValue({ closed_issue_iid: 1 }), }; @@ -302,11 +315,6 @@ describe('ErrorDetails', () => { }); describe('Status update', () => { - const findUpdateIgnoreStatusButton = () => - wrapper.find('[data-qa-selector="update_ignore_status_button"]'); - const findUpdateResolveStatusButton = () => - wrapper.find('[data-qa-selector="update_resolve_status_button"]'); - afterEach(() => { actions.updateIgnoreStatus.mockClear(); actions.updateResolveStatus.mockClear(); @@ -491,4 +499,55 @@ describe('ErrorDetails', () => { }); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mocks.$apollo.queries.error.loading = false; + mountComponent(); + wrapper.setData({ + error: { externalUrl }, + }); + }); + + it('should track detail page views', () => { + const { category, action } = trackErrorDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track IGNORE status update', () => { + Tracking.event.mockClear(); + findUpdateIgnoreStatusButton().vm.$emit('click'); + setImmediate(() => { + const { category, action, label } = trackErrorStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { + label, + property: 'ignored', + }); + }); + }); + + it('should track RESOLVE status update', () => { + Tracking.event.mockClear(); + findUpdateResolveStatusButton().vm.$emit('click'); + setImmediate(() => { + const { category, action, label } = trackErrorStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { + label, + property: 'resolved', + }); + }); + }); + + it('should track external Sentry link views', () => { + Tracking.event.mockClear(); + findExternalUrl().trigger('click'); + setImmediate(() => { + const { category, action, label, property } = trackClickErrorLinkToSentryOptions( + externalUrl, + ); + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property }); + }); + }); + }); }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index a6cb074f481..b5e09c86d08 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -4,7 +4,9 @@ import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } fr import stubChildren from 'helpers/stub_children'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue'; +import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '~/error_tracking/utils'; import errorsList from './list_mock.json'; +import Tracking from '~/tracking'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -460,4 +462,41 @@ describe('ErrorTrackingList', () => { }); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + store.state.list.loading = false; + store.state.list.errors = errorsList; + mountComponent({ + stubs: { + GlTable: false, + GlLink: false, + GlDeprecatedButton: false, + }, + }); + }); + + it('should track list views', () => { + const { category, action } = trackErrorListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track status updates', () => { + Tracking.event.mockClear(); + const status = 'ignored'; + findErrorActions().vm.$emit('update-issue-status', { + errorId: 1, + status, + }); + + setImmediate(() => { + const { category, action, label } = trackErrorStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { + label, + property: status, + }); + }); + }); + }); }); diff --git a/spec/lib/gitlab/import_export/importer_spec.rb b/spec/lib/gitlab/import_export/importer_spec.rb index 60179146416..b8c34a461ef 100644 --- a/spec/lib/gitlab/import_export/importer_spec.rb +++ b/spec/lib/gitlab/import_export/importer_spec.rb @@ -18,6 +18,7 @@ describe Gitlab::ImportExport::Importer do FileUtils.mkdir_p(shared.export_path) ImportExportUpload.create(project: project, import_file: import_file) + allow(FileUtils).to receive(:rm_rf).and_call_original end after do @@ -78,6 +79,13 @@ describe Gitlab::ImportExport::Importer do expect(project.import_export_upload.import_file&.file).to be_nil end + it 'removes tmp files' do + importer.execute + + expect(FileUtils).to have_received(:rm_rf).with(shared.base_path) + expect(Dir.exist?(shared.base_path)).to eq(false) + end + it 'sets the correct visibility_level when visibility level is a string' do project.create_or_update_import_data( data: { override_params: { visibility_level: Gitlab::VisibilityLevel::PRIVATE.to_s } } diff --git a/spec/lib/gitlab/import_export/saver_spec.rb b/spec/lib/gitlab/import_export/saver_spec.rb index a59cf7a1260..18e9d7da32d 100644 --- a/spec/lib/gitlab/import_export/saver_spec.rb +++ b/spec/lib/gitlab/import_export/saver_spec.rb @@ -5,18 +5,21 @@ require 'fileutils' describe Gitlab::ImportExport::Saver do let!(:project) { create(:project, :public, name: 'project') } - let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } + let(:base_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } + let(:export_path) { "#{base_path}/project_tree_saver_spec/export" } let(:shared) { project.import_export_shared } subject { described_class.new(exportable: project, shared: shared) } before do + allow(shared).to receive(:base_path).and_return(base_path) allow_next_instance_of(Gitlab::ImportExport) do |instance| allow(instance).to receive(:storage_path).and_return(export_path) end FileUtils.mkdir_p(shared.export_path) FileUtils.touch("#{shared.export_path}/tmp.bundle") + allow(FileUtils).to receive(:rm_rf).and_call_original end after do @@ -31,4 +34,11 @@ describe Gitlab::ImportExport::Saver do expect(ImportExportUpload.find_by(project: project).export_file.url) .to match(%r[\/uploads\/-\/system\/import_export_upload\/export_file.*]) end + + it 'removes tmp files' do + subject.save + + expect(FileUtils).to have_received(:rm_rf).with(base_path) + expect(Dir.exist?(base_path)).to eq(false) + end end diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb index 4ad168ff0f2..b887a06c5af 100644 --- a/spec/models/ci/instance_variable_spec.rb +++ b/spec/models/ci/instance_variable_spec.rb @@ -9,6 +9,7 @@ describe Ci::InstanceVariable do it { is_expected.to include_module(Ci::Maskable) } it { is_expected.to validate_uniqueness_of(:key).with_message(/\(\w+\) has already been taken/) } + it { is_expected.to validate_length_of(:encrypted_value).is_at_most(1024).with_message(/Variables over 700 characters risk exceeding the limit/) } describe '.unprotected' do subject { described_class.unprotected } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 687ae71cdab..4a954be047f 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1007,19 +1007,6 @@ describe Ci::Pipeline, :mailer do subject { pipeline.ordered_stages } - context 'when using legacy stages' do - before do - stub_feature_flags( - ci_pipeline_persisted_stages: false, - ci_atomic_processing: false - ) - end - - it 'returns legacy stages in valid order' do - expect(subject.map(&:name)).to eq %w[build test] - end - end - context 'when using atomic processing' do before do stub_feature_flags( @@ -1051,7 +1038,6 @@ describe Ci::Pipeline, :mailer do context 'when using persisted stages' do before do stub_feature_flags( - ci_pipeline_persisted_stages: true, ci_atomic_processing: false ) end diff --git a/spec/models/resource_milestone_event_spec.rb b/spec/models/resource_milestone_event_spec.rb index 3f8d8b4c1df..66686ec77d0 100644 --- a/spec/models/resource_milestone_event_spec.rb +++ b/spec/models/resource_milestone_event_spec.rb @@ -95,4 +95,34 @@ describe ResourceMilestoneEvent, type: :model do end end end + + describe '#milestone_parent' do + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + + let(:milestone) { create(:milestone, project: project) } + let(:event) { create(:resource_milestone_event, milestone: milestone) } + + context 'when milestone parent is project' do + it 'returns the expected parent' do + expect(event.milestone_parent).to eq(project) + end + end + + context 'when milestone parent is group' do + let(:milestone) { create(:milestone, group: group) } + + it 'returns the expected parent' do + expect(event.milestone_parent).to eq(group) + end + end + + context 'when milestone is nil' do + let(:event) { create(:resource_milestone_event, milestone: nil) } + + it 'returns nil' do + expect(event.milestone_parent).to be_nil + end + end + end end diff --git a/spec/requests/api/admin/ci/variables_spec.rb b/spec/requests/api/admin/ci/variables_spec.rb index bc2f0ba50a2..185fde17e1b 100644 --- a/spec/requests/api/admin/ci/variables_spec.rb +++ b/spec/requests/api/admin/ci/variables_spec.rb @@ -109,6 +109,22 @@ describe ::API::Admin::Ci::Variables do expect(response).to have_gitlab_http_status(:bad_request) end + + it 'does not allow values above 700 characters' do + too_long_message = <<~MESSAGE.strip + The encrypted value of the provided variable exceeds 1024 bytes. \ + Variables over 700 characters risk exceeding the limit. + MESSAGE + + expect do + post api('/admin/ci/variables', admin), + params: { key: 'too_long', value: SecureRandom.hex(701) } + end.not_to change { ::Ci::InstanceVariable.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to match('message' => + a_hash_including('encrypted_value' => [too_long_message])) + end end context 'authorized user with invalid permissions' do diff --git a/spec/requests/api/resource_milestone_events_spec.rb b/spec/requests/api/resource_milestone_events_spec.rb new file mode 100644 index 00000000000..b2e92fde5ee --- /dev/null +++ b/spec/requests/api/resource_milestone_events_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::ResourceMilestoneEvents do + let!(:user) { create(:user) } + let!(:project) { create(:project, :public, namespace: user.namespace) } + let!(:milestone) { create(:milestone, project: project) } + + before do + project.add_developer(user) + end + + context 'when eventable is an Issue' do + it_behaves_like 'resource_milestone_events API', 'projects', 'issues', 'iid' do + let(:parent) { project } + let(:eventable) { create(:issue, project: project, author: user) } + end + end + + context 'when eventable is a Merge Request' do + it_behaves_like 'resource_milestone_events API', 'projects', 'merge_requests', 'iid' do + let(:parent) { project } + let(:eventable) { create(:merge_request, source_project: project, target_project: project, author: user) } + end + end +end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index 4e4cc9c35e6..c8f25423f85 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -10,10 +10,6 @@ describe PipelineSerializer do described_class.new(current_user: user, project: project) end - before do - stub_feature_flags(ci_pipeline_persisted_stages: true) - end - subject { serializer.represent(resource) } describe '#represent' do diff --git a/spec/services/groups/import_export/export_service_spec.rb b/spec/services/groups/import_export/export_service_spec.rb index 4576c786416..ea49b26cc7c 100644 --- a/spec/services/groups/import_export/export_service_spec.rb +++ b/spec/services/groups/import_export/export_service_spec.rb @@ -134,7 +134,7 @@ describe Groups::ImportExport::ExportService do expect { service.execute }.to raise_error(Gitlab::ImportExport::Error) expect(group.import_export_upload).to be_nil - expect(File.exist?(shared.archive_path)).to eq(false) + expect(Dir.exist?(shared.base_path)).to eq(false) end it 'notifies the user about failed group export' do @@ -159,7 +159,7 @@ describe Groups::ImportExport::ExportService do expect { service.execute }.to raise_error(Gitlab::ImportExport::Error) expect(group.import_export_upload).to be_nil - expect(File.exist?(shared.archive_path)).to eq(false) + expect(Dir.exist?(shared.base_path)).to eq(false) end it 'notifies logger' do diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb index b1eae057c47..065be36c1dc 100644 --- a/spec/services/groups/import_export/import_service_spec.rb +++ b/spec/services/groups/import_export/import_service_spec.rb @@ -91,6 +91,7 @@ describe Groups::ImportExport::ImportService do allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger) allow(import_logger).to receive(:error) allow(import_logger).to receive(:info) + allow(FileUtils).to receive(:rm_rf).and_call_original end context 'when user has correct permissions' do @@ -104,6 +105,16 @@ describe Groups::ImportExport::ImportService do expect(group.import_export_upload.import_file.file).to be_nil end + it 'removes tmp files' do + shared = Gitlab::ImportExport::Shared.new(group) + allow(Gitlab::ImportExport::Shared).to receive(:new).and_return(shared) + + subject + + expect(FileUtils).to have_received(:rm_rf).with(shared.base_path) + expect(Dir.exist?(shared.base_path)).to eq(false) + end + it 'logs the import success' do expect(import_logger).to receive(:info).with( group_id: group.id, @@ -191,6 +202,7 @@ describe Groups::ImportExport::ImportService do allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger) allow(import_logger).to receive(:error) allow(import_logger).to receive(:info) + allow(FileUtils).to receive(:rm_rf).and_call_original end context 'when user has correct permissions' do @@ -204,6 +216,16 @@ describe Groups::ImportExport::ImportService do expect(group.import_export_upload.import_file.file).to be_nil end + it 'removes tmp files' do + shared = Gitlab::ImportExport::Shared.new(group) + allow(Gitlab::ImportExport::Shared).to receive(:new).and_return(shared) + + subject + + expect(FileUtils).to have_received(:rm_rf).with(shared.base_path) + expect(Dir.exist?(shared.base_path)).to eq(false) + end + it 'logs the import success' do expect(import_logger).to receive(:info).with( group_id: group.id, diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb index e52ac02a2cc..6e584cb44e2 100644 --- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb +++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb @@ -25,7 +25,7 @@ RSpec.shared_examples 'thread comments' do |resource_name| end if resource_name == 'issue' - it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/218757' do + it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do find("#{form_selector} .note-textarea").send_keys(comment) click_button 'Comment & close issue' @@ -206,7 +206,7 @@ RSpec.shared_examples 'thread comments' do |resource_name| end if resource_name == 'issue' - it "clicking 'Start thread & close #{resource_name}' will post a thread and close the #{resource_name}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/218757' do + it "clicking 'Start thread & close #{resource_name}' will post a thread and close the #{resource_name}" do click_button 'Start thread & close issue' expect(page).to have_content(comment) diff --git a/spec/support/shared_examples/requests/api/resource_milestone_events_api_shared_examples.rb b/spec/support/shared_examples/requests/api/resource_milestone_events_api_shared_examples.rb new file mode 100644 index 00000000000..bca51dab353 --- /dev/null +++ b/spec/support/shared_examples/requests/api/resource_milestone_events_api_shared_examples.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'resource_milestone_events API' do |parent_type, eventable_type, id_name| + describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_milestone_events" do + let!(:event) { create_event(milestone) } + + it "returns an array of resource milestone events" do + url = "/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events" + get api(url, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['id']).to eq(event.id) + expect(json_response.first['milestone']['id']).to eq(event.milestone.id) + expect(json_response.first['action']).to eq(event.action) + end + + it "returns a 404 error when eventable id not found" do + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{non_existing_record_id}/resource_milestone_events", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + private_user = create(:user) + + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events", private_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_milestone_events/:event_id" do + let!(:event) { create_event(milestone) } + + it "returns a resource milestone event by id" do + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events/#{event.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(event.id) + expect(json_response['milestone']['id']).to eq(event.milestone.id) + expect(json_response['action']).to eq(event.action) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + private_user = create(:user) + + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events/#{event.id}", private_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it "returns a 404 error if resource milestone event not found" do + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events/#{non_existing_record_id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + def create_event(milestone, action: :add) + create(:resource_milestone_event, eventable.class.name.underscore => eventable, milestone: milestone, action: action) + end +end |