From e36443c1d657343410d3de25d52ae0fe9ee67d8d Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 2 Nov 2022 15:11:07 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- Gemfile | 2 +- Gemfile.checksum | 2 +- Gemfile.lock | 4 +- .../javascripts/notes/stores/getters.js | 6 +- .../javascripts/repository/constants.js | 1 - .../plugins/link_dependencies.js | 2 + .../plugins/utils/godeps_json_linker.js | 64 ++++++++++++++++ .../stylesheets/framework/calendar.scss | 2 - .../stylesheets/framework/variables.scss | 24 ++++-- .../concerns/ci/partitionable/switch.rb | 4 +- app/models/users/ghost_user_migration.rb | 2 + ...ecords_to_ghost_user_in_batches_service.rb | 25 +++++-- ..._consume_after_to_ghost_user_migrations.rb | 7 ++ ...me_after_index_to_ghost_user_migrations.rb | 15 ++++ db/schema_migrations/20221018124029 | 1 + db/schema_migrations/20221018124035 | 1 + db/structure.sql | 5 +- doc/administration/gitaly/reference.md | 3 +- doc/api/merge_requests.md | 63 +++++++++++++++- .../bitbucket_integration.md | 25 +++---- doc/development/i18n/externalization.md | 30 +++++++- lib/api/api.rb | 2 +- lib/api/entities/protected_branch.rb | 10 +-- lib/api/entities/protected_ref_access.rb | 11 +-- lib/api/protected_branches.rb | 47 +++++++++--- spec/factories/users/ghost_user_migrations.rb | 1 + .../notes/components/note_actions_spec.js | 74 ++++++++++++++++++- spec/frontend/notes/stores/getters_spec.js | 22 +++++- .../plugins/link_dependencies_spec.js | 14 +++- .../source_viewer/plugins/mock_data.js | 2 + .../plugins/utils/godeps_json_linker_spec.js | 27 +++++++ spec/lib/gitlab/usage_data_spec.rb | 4 - .../concerns/ci/partitionable/switch_spec.rb | 22 ++++++ .../models/users/ghost_user_migration_spec.rb | 13 +++- ...s_to_ghost_user_in_batches_service_spec.rb | 29 ++++++++ 35 files changed, 495 insertions(+), 71 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js create mode 100644 db/migrate/20221018124029_add_consume_after_to_ghost_user_migrations.rb create mode 100644 db/migrate/20221018124035_add_consume_after_index_to_ghost_user_migrations.rb create mode 100644 db/schema_migrations/20221018124029 create mode 100644 db/schema_migrations/20221018124035 create mode 100644 spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js diff --git a/Gemfile b/Gemfile index 82e5bababc3..44fee01063f 100644 --- a/Gemfile +++ b/Gemfile @@ -142,7 +142,7 @@ gem 'carrierwave', '~> 1.3' gem 'mini_magick', '~> 4.10.1' # for backups -gem 'fog-aws', '~> 3.14' +gem 'fog-aws', '~> 3.15' # Locked until fog-google resolves https://github.com/fog/fog-google/issues/421. # Also see config/initializers/fog_core_patch.rb. gem 'fog-core', '= 2.1.0' diff --git a/Gemfile.checksum b/Gemfile.checksum index 5a7956df3d9..d7b8bb148b1 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -180,7 +180,7 @@ {"name":"flipper-active_support_cache_store","version":"0.25.0","platform":"ruby","checksum":"7282bf994b08d1a076b65c6f3b51e3dc04fcb00fa6e7b20089e60db25c7b531b"}, {"name":"flowdock","version":"0.7.1","platform":"ruby","checksum":"cfa95b2ac96e5f883f6e419d7a891f76cfcc17a28c416b6b714bbdffc8dbd912"}, {"name":"fog-aliyun","version":"0.3.3","platform":"ruby","checksum":"d0aa317f7c1473a1d684fff51699f216bb9cb78b9ee9ce55a81c9bcc93fb85ee"}, -{"name":"fog-aws","version":"3.14.0","platform":"ruby","checksum":"07442dff8ee2a314413f812d6f6052e7d4a444540df84c193c135c1994114bbf"}, +{"name":"fog-aws","version":"3.15.0","platform":"ruby","checksum":"09752931ea0c6165b018e1a89253248d86b246645086ccf19bc44fabe3381e8c"}, {"name":"fog-core","version":"2.1.0","platform":"ruby","checksum":"53e5d793554d7080d015ef13cd44b54027e421d924d9dba4ce3d83f95f37eda9"}, {"name":"fog-google","version":"1.15.0","platform":"ruby","checksum":"2f840780fbf2384718e961b05ef2fc522b4213bbda6f25b28c1bbd875ff0b306"}, {"name":"fog-json","version":"1.2.0","platform":"ruby","checksum":"dd4f5ab362dbc72b687240bba9d2dd841d5dfe888a285797533f85c03ea548fe"}, diff --git a/Gemfile.lock b/Gemfile.lock index 31ec54a5347..04fe8d6ee48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -492,7 +492,7 @@ GEM fog-json ipaddress (~> 0.8) xml-simple (~> 1.1) - fog-aws (3.14.0) + fog-aws (3.15.0) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -1614,7 +1614,7 @@ DEPENDENCIES flipper-active_support_cache_store (~> 0.25.0) flowdock (~> 0.7) fog-aliyun (~> 0.3) - fog-aws (~> 3.14) + fog-aws (~> 3.15) fog-core (= 2.1.0) fog-google (~> 1.15) fog-local (~> 0.6) diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 6876220f75c..5ad7a811726 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -94,9 +94,9 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us export const descriptionVersions = (state) => state.descriptionVersions; export const canUserAddIncidentTimelineEvents = (state) => { - return ( - state.userData.can_add_timeline_events && - state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident + return Boolean( + state.userData?.can_add_timeline_events && + state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident, ); }; diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 77d3a517d28..c24fb686bee 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -99,7 +99,6 @@ export const LEGACY_FILE_TYPES = [ 'podspec', 'podspec_json', 'cartfile', - 'godeps_json', 'requirements_txt', 'cargo_toml', 'go_mod', diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js index d957990fe7f..597fc00a004 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -1,9 +1,11 @@ import packageJsonLinker from './utils/package_json_linker'; import gemspecLinker from './utils/gemspec_linker'; +import godepsJsonLinker from './utils/godeps_json_linker'; const DEPENDENCY_LINKERS = { package_json: packageJsonLinker, gemspec: gemspecLinker, + godeps_json: godepsJsonLinker, }; /** diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js new file mode 100644 index 00000000000..bff8e3cf410 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker.js @@ -0,0 +1,64 @@ +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const PROTOCOL = 'https://'; +const GODOCS_DOMAIN = 'godoc.org/'; +const REPO_PATH = '/tree/master/'; +const GODOCS_REGEX = /golang.org/; +const GITLAB_REPO_PATH = `/_${REPO_PATH}`; +const REPO_REGEX = `[^/'"]+/[^/'"]+`; +const NESTED_REPO_REGEX = '([^/]+/)+[^/]+?'; +const GITHUB_REPO_REGEX = new RegExp(`(github.com/${REPO_REGEX})/(.+)`); +const GITLAB_REPO_REGEX = new RegExp(`(gitlab.com/${REPO_REGEX})/(.+)`); +const GITLAB_NESTED_REPO_REGEX = new RegExp(`(gitlab.com/${NESTED_REPO_REGEX}).git/(.+)`); +const attrOpenTag = generateHLJSOpenTag('attr'); +const stringOpenTag = generateHLJSOpenTag('string'); +const closeTag = '"'; +const importPathString = + 'ImportPath": '; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: "ImportPath": "github.com/ayufan/golang-kardianos-service" + * Group 1: github.com/ayufan/golang-kardianos-service + */ + `${importPathString}${stringOpenTag}(.*)${closeTag}`, + 'gm', +); + +const replaceRepoPath = (dependency, regex, repoPath) => + dependency.replace(regex, (_, repo, path) => `${PROTOCOL}${repo}${repoPath}${path}`); + +const regexConfigs = [ + { + matcher: GITHUB_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITHUB_REPO_REGEX, REPO_PATH), + }, + { + matcher: GITLAB_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITLAB_REPO_REGEX, GITLAB_REPO_PATH), + }, + { + matcher: GITLAB_NESTED_REPO_REGEX, + resolver: (dep) => replaceRepoPath(dep, GITLAB_NESTED_REPO_REGEX, GITLAB_REPO_PATH), + }, + { + matcher: GODOCS_REGEX, + resolver: (dep) => `${PROTOCOL}${GODOCS_DOMAIN}${dep}`, + }, +]; + +const getLinkHref = (dependency) => { + const regexConfig = regexConfigs.find((config) => dependency.match(config.matcher)); + return regexConfig ? regexConfig.resolver(dependency) : `${PROTOCOL}${dependency}`; +}; + +const handleReplace = (dependency) => { + const linkHref = getLinkHref(dependency); + const link = createLink(linkHref, dependency); + return `${importPathString}${attrOpenTag}${link}${closeTag}`; +}; + +export default (result) => { + return result.value.replace(DEPENDENCY_REGEX, (_, dependency) => handleReplace(dependency)); +}; diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index e69d7b4462d..27e9a041145 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -1,6 +1,4 @@ .user-contrib-cell { - stroke: $t-gray-a-08; - &:hover { cursor: pointer; stroke: $black; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 9cfc5a0201e..95e03abbb48 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -346,6 +346,20 @@ $theme-light-red-500: #c24b38; $theme-light-red-600: #b03927; $theme-light-red-700: #a62e21; +// Data visualization color palette + +$data-viz-blue-50: #e9ebff; +$data-viz-blue-100: #d4dcfa; +$data-viz-blue-200: #b7c6ff; +$data-viz-blue-300: #97acff; +$data-viz-blue-400: #748eff; +$data-viz-blue-500: #5772ff; +$data-viz-blue-600: #445cf2; +$data-viz-blue-700: #3547de; +$data-viz-blue-800: #232fcf; +$data-viz-blue-900: #1e23a8; +$data-viz-blue-950: #11118a; + $border-white-light: darken($white, $darken-border-factor) !default; $border-white-normal: darken($white-normal, $darken-border-factor) !default; @@ -710,11 +724,11 @@ $job-arrow-margin: 55px; */ // See https://gitlab.com/gitlab-org/gitlab/-/issues/332150 to align with Pajamas Design System $calendar-activity-colors: ( - #f5f5f5, - #d4dcfa, - #748eff, - #3547de, - #11118a, + $gray-50, + $data-viz-blue-100, + $data-viz-blue-400, + $data-viz-blue-700, + $data-viz-blue-950, ) !default; /* diff --git a/app/models/concerns/ci/partitionable/switch.rb b/app/models/concerns/ci/partitionable/switch.rb index 032f34fba1f..c1bbd107e9f 100644 --- a/app/models/concerns/ci/partitionable/switch.rb +++ b/app/models/concerns/ci/partitionable/switch.rb @@ -33,7 +33,9 @@ module Ci def routing_table_enabled? return false if routing_class? - ::Feature.enabled?(routing_table_name_flag) + Gitlab::SafeRequestStore.fetch(routing_table_name_flag) do + ::Feature.enabled?(routing_table_name_flag) + end end # We're delegating them to the `Partitioned` model. diff --git a/app/models/users/ghost_user_migration.rb b/app/models/users/ghost_user_migration.rb index 1d93498e88b..4578e0503c3 100644 --- a/app/models/users/ghost_user_migration.rb +++ b/app/models/users/ghost_user_migration.rb @@ -8,5 +8,7 @@ module Users belongs_to :initiator_user, class_name: 'User' validates :user_id, presence: true + + scope :consume_order, -> { order(:consume_after, :id) } end end diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb index 7c4a5698ea9..d294312cc30 100644 --- a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb +++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb @@ -2,25 +2,38 @@ module Users class MigrateRecordsToGhostUserInBatchesService + LIMIT_SIZE = 1000 + def initialize @execution_tracker = Gitlab::Utils::ExecutionTracker.new end def execute - Users::GhostUserMigration.find_each do |user_to_migrate| + ghost_user_migrations.each do |job| break if execution_tracker.over_limit? - service = Users::MigrateRecordsToGhostUserService.new(user_to_migrate.user, - user_to_migrate.initiator_user, + service = Users::MigrateRecordsToGhostUserService.new(job.user, + job.initiator_user, execution_tracker) - service.execute(hard_delete: user_to_migrate.hard_delete) + service.execute(hard_delete: job.hard_delete) + rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError + # no-op + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e) + reschedule(job) end - rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError - # no-op end private attr_reader :execution_tracker + + def ghost_user_migrations + Users::GhostUserMigration.consume_order.limit(LIMIT_SIZE) + end + + def reschedule(job) + job.update(consume_after: 30.minutes.from_now) + end end end diff --git a/db/migrate/20221018124029_add_consume_after_to_ghost_user_migrations.rb b/db/migrate/20221018124029_add_consume_after_to_ghost_user_migrations.rb new file mode 100644 index 00000000000..148c6516dc9 --- /dev/null +++ b/db/migrate/20221018124029_add_consume_after_to_ghost_user_migrations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddConsumeAfterToGhostUserMigrations < Gitlab::Database::Migration[2.0] + def change + add_column :ghost_user_migrations, :consume_after, :datetime_with_timezone, null: false, default: -> { 'NOW()' } + end +end diff --git a/db/migrate/20221018124035_add_consume_after_index_to_ghost_user_migrations.rb b/db/migrate/20221018124035_add_consume_after_index_to_ghost_user_migrations.rb new file mode 100644 index 00000000000..543d91b3f33 --- /dev/null +++ b/db/migrate/20221018124035_add_consume_after_index_to_ghost_user_migrations.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddConsumeAfterIndexToGhostUserMigrations < Gitlab::Database::Migration[2.0] + INDEX_NAME = 'index_ghost_user_migrations_on_consume_after_id' + + disable_ddl_transaction! + + def up + add_concurrent_index :ghost_user_migrations, [:consume_after, :id], name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :ghost_user_migrations, INDEX_NAME + end +end diff --git a/db/schema_migrations/20221018124029 b/db/schema_migrations/20221018124029 new file mode 100644 index 00000000000..6c050ebf248 --- /dev/null +++ b/db/schema_migrations/20221018124029 @@ -0,0 +1 @@ +c3a38f280c8835e77953b69ba41ef5d58b76fd5f2f39e758a523c493306b0ab2 \ No newline at end of file diff --git a/db/schema_migrations/20221018124035 b/db/schema_migrations/20221018124035 new file mode 100644 index 00000000000..1d0721c4bfb --- /dev/null +++ b/db/schema_migrations/20221018124035 @@ -0,0 +1 @@ +77aca033a7c58af4e981136b96629acf5b82a42701072928532681dd91b05280 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index cef651cff2d..45ad50dc5fe 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15859,7 +15859,8 @@ CREATE TABLE ghost_user_migrations ( initiator_user_id bigint, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - hard_delete boolean DEFAULT false NOT NULL + hard_delete boolean DEFAULT false NOT NULL, + consume_after timestamp with time zone DEFAULT now() NOT NULL ); CREATE SEQUENCE ghost_user_migrations_id_seq @@ -29059,6 +29060,8 @@ CREATE INDEX index_geo_repository_updated_events_on_source ON geo_repository_upd CREATE INDEX index_geo_reset_checksum_events_on_project_id ON geo_reset_checksum_events USING btree (project_id); +CREATE INDEX index_ghost_user_migrations_on_consume_after_id ON ghost_user_migrations USING btree (consume_after, id); + CREATE UNIQUE INDEX index_ghost_user_migrations_on_user_id ON ghost_user_migrations USING btree (user_id); CREATE INDEX index_gin_ci_namespace_mirrors_on_traversal_ids ON ci_namespace_mirrors USING gin (traversal_ids); diff --git a/doc/administration/gitaly/reference.md b/doc/administration/gitaly/reference.md index 3bf1e3136c0..8f7dc688e56 100644 --- a/doc/administration/gitaly/reference.md +++ b/doc/administration/gitaly/reference.md @@ -192,8 +192,7 @@ For historical reasons [GitLab Shell](https://gitlab.com/gitlab-org/gitlab-shell) contains the Git hooks that allow GitLab to validate and react to Git pushes. Because Gitaly "owns" Git pushes, GitLab Shell must therefore be -installed alongside Gitaly. We plan to -[simplify this](https://gitlab.com/gitlab-org/gitaly/-/issues/1226). +installed alongside Gitaly. | Name | Type | Required | Description | | ---- | ---- | -------- | ----------- | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index ba4a3292ce8..a09ca5a548c 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -619,6 +619,67 @@ Supported attributes: | `include_rebase_in_progress` | boolean | **{dotted-circle}** No | If `true`, response includes whether a rebase operation is in progress. | | `render_html` | boolean | **{dotted-circle}** No | If `true`, response includes rendered HTML for title and description. | +### Response + +| Attribute | Type | Description | +|----------------------------------|------|-------------| +| `approvals_before_merge` | integer | **(PREMIUM)** Number of approvals required before this can be merged. | +| `assignee` | object | First assignee of the merge request. | +| `assignees` | array | Assignees of the merge request. | +| `author` | object | User who created this merge request. | +| `blocking_discussions_resolved` | boolean | Indicates if all discussions are resolved only if all are required before merge request can be merged. | +| `changes_count` | string | Number of changes made on the merge request. | +| `closed_at` | datetime | Timestamp of when the merge request was closed. | +| `closed_by` | object | User who closed this merge request. | +| `created_at` | datetime | Timestamp of when the merge request was created. | +| `description` | string | Description of the merge request (Markdown rendered as HTML for caching). | +| `detailed_merge_status` | string | Detailed merge status of the merge request. | +| `diff_refs` | object | References of the base SHA, the head SHA, and the start SHA for this merge request. | +| `discussion_locked` | boolean | Indicates if comments on the merge request are locked to members only. | +| `downvotes` | integer | Number of downvotes for the merge request. | +| `draft` | boolean | Indicates if the merge request is a draft. | +| `first_contribution` | boolean | Indicates if the merge request is the first contribution of the author. | +| `first_deployed_to_production_at` | datetime | Timestamp of when the first deployment finished. | +| `force_remove_source_branch` | boolean | Indicates if the project settings will lead to source branch deletion after merge. | +| `has_conflicts` | boolean | Indicates if merge request has conflicts and cannot be merged. | +| `head_pipeline` | object | Pipeline running on the branch HEAD of the merge request. | +| `id` | integer | ID of the merge request. | +| `iid` | integer | Internal ID of the merge request. | +| `labels` | array | Labels of the merge request. | +| `latest_build_finished_at` | datetime | Timestamp of when the latest build for the merge request finished. | +| `latest_build_started_at` | datetime | Timestamp of when the latest build for the merge request started. | +| `merge_commit_sha` | string | SHA of the merge request commit (set once merged). | +| `merge_error` | string | Error message due to a merge error. | +| `merge_user` | object | User who merged this merge request or set it to merge when pipeline succeeds. | +| `merge_status` | string | Status of the merge request. Can be `unchecked`, `checking`, `can_be_merged`, `cannot_be_merged` or `cannot_be_merged_recheck`. | +| `merge_when_pipeline_succeeds` | boolean | Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS). | +| `merged_at` | datetime | Timestamp of when the merge request was merged. | +| `merged_by` | object | Deprecated: Use `merge_user` instead. User who merged this merge request or set it to merge when pipeline succeeds. | +| `milestone` | object | Milestone of the merge request. | +| `pipeline` | object | Pipeline running on the branch HEAD of the merge request. | +| `project_id` | integer | ID of the merge request project. | +| `reference` | string | Deprecated: Use `references` instead. Internal reference of the merge request. Returned in shortened format by default. | +| `references` | object | Internal references of the merge request. Includes `short`, `relative` and `full` references. | +| `reviewers` | array | Reviewers of the merge request. | +| `sha` | string | Diff head SHA of the merge request. | +| `should_remove_source_branch` | boolean | Indicates if the source branch of the merge request will be deleted after merge. | +| `source_branch` | string | Source branch of the merge request. | +| `source_project_id` | integer | ID of the merge request source project. | +| `squash` | boolean | Indicates if squash on merge is enabled. | +| `squash_commit_sha` | string | SHA of the squash commit (set once merged). | +| `state` | string | State of the merge request. Can be `opened`, `closed`, `merged` or `locked`. | +| `subscribed` | boolean | Indicates if the currently logged in user is subscribed to this merge request. | +| `target_branch` | string | Target branch of the merge request. | +| `target_project_id` | integer | ID of the merge request target project. | +| `task_completion_status` | object | Completion status of tasks. | +| `title` | string | Title of the merge request. | +| `updated_at` | datetime | Timestamp of when the merge request was updated. | +| `upvotes` | integer | Number of upvotes for the merge request. | +| `user` | object | Permissions of the user requested for the merge request. | +| `user_notes_count` | integer | User notes count of the merge request. | +| `web_url` | string | Web URL of the merge request. | +| `work_in_progress` | boolean | Deprecated: Use `draft` instead. Indicates if the merge request is a draft. | + ```json { "id": 155016530, @@ -787,7 +848,7 @@ the `approvals_before_merge` parameter: ### Merge status -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/101724) in GitLab 15.6. +> The `detailed_merge_status` field was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/101724) in GitLab 15.6. - The `merge_status` field may hold one of the following values: - `unchecked`: This merge request has not yet been checked. diff --git a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md index 6256f11f1ea..3c4c33aa32c 100644 --- a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md +++ b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md @@ -25,11 +25,11 @@ To use GitLab CI/CD with a Bitbucket Cloud repository: - You can generate and use a [Bitbucket App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) for the password field. GitLab imports the repository and enables [Pull Mirroring](../../user/project/repository/mirror/pull.md). - You can check that mirroring is working in the project by going to **Settings > Repository > Mirroring repositories**. + You can check that mirroring is working in the project in **Settings > Repository > Mirroring repositories**. 1. In GitLab, create a [Personal Access Token](../../user/profile/personal_access_tokens.md) - with `api` scope. This is used to authenticate requests from the web + with `api` scope. The token is used to authenticate requests from the web hook that is created in Bitbucket to notify GitLab of new commits. 1. In Bitbucket, from **Settings > Webhooks**, create a new web hook to notify @@ -58,18 +58,14 @@ To use GitLab CI/CD with a Bitbucket Cloud repository: 1. In GitLab, from **Settings > CI/CD > Variables**, add variables to allow communication with Bitbucket via the Bitbucket API: - `BITBUCKET_ACCESS_TOKEN`: the Bitbucket app password created above. + - `BITBUCKET_ACCESS_TOKEN`: The Bitbucket app password created above. This variable should be [masked](../variables/index.md#mask-a-cicd-variable). + - `BITBUCKET_USERNAME`: The username of the Bitbucket account. + - `BITBUCKET_NAMESPACE`: Set this variable if your GitLab and Bitbucket namespaces differ. + - `BITBUCKET_REPOSITORY`: Set this variable if your GitLab and Bitbucket project names differ. - `BITBUCKET_USERNAME`: the username of the Bitbucket account. - - `BITBUCKET_NAMESPACE`: set this if your GitLab and Bitbucket namespaces differ. - - `BITBUCKET_REPOSITORY`: set this if your GitLab and Bitbucket project names differ. - -1. In Bitbucket, add a script to push the pipeline status to Bitbucket. - - NOTE: - The changes must be made in Bitbucket as any changes in the GitLab repository are overwritten by Bitbucket when GitLab next mirrors the repository. +1. In Bitbucket, add a script that pushes the pipeline status to Bitbucket. The script + is created in Bitbucket, but the mirroring process copies it to the GitLab mirror. The GitLab + CI/CD pipeline runs the script, and pushes the status back to Bitbucket. Create a file `build_status` and insert the script below and run `chmod +x build_status` in your terminal to make the script executable. @@ -125,7 +121,8 @@ To use GitLab CI/CD with a Bitbucket Cloud repository: ``` 1. In Bitbucket, create a `.gitlab-ci.yml` file to use the script to push - pipeline success and failures to Bitbucket. + pipeline success and failures to Bitbucket. Similar to the script added above, + this file is copied to the GitLab repo as part of the mirroring process. ```yaml stages: diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 158eb19764b..91e2efcb2a3 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -463,12 +463,17 @@ use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. Make sure to The `n_` and `n__` methods should only be used to fetch pluralized translations of the same string, not to control the logic of showing different strings for different -quantities. Some languages have different quantities of target plural forms. +quantities. For similar strings, pluralize the entire sentence to provide the most context +when translating. Some languages have different quantities of target plural forms. For example, Chinese (simplified) has only one target plural form in our translation tool. This means the translator has to choose to translate only one of the strings, and the translation doesn't behave as intended in the other case. -For example, use this: +Below are some examples: + +Example 1: For different strings + +Use this: ```ruby if selected_projects.one? @@ -485,6 +490,27 @@ Instead of this: format(n_("%{project_name}", "%d projects selected", count), project_name: 'GitLab') ``` +Example 2: For similar strings + +Use this: + +```ruby +n__('Last day', 'Last %d days', days.length) +``` + +Instead of this: + +```ruby +# incorrect usage example +const pluralize = n__('day', 'days', days.length) + +if (days.length === 1 ) { + return sprintf(s__('Last %{pluralize}', pluralize) +} + +return sprintf(s__('Last %{dayNumber} %{pluralize}'), { dayNumber: days.length, pluralize }) +``` + ### Namespaces A namespace is a way to group translations that belong together. They provide context to our diff --git a/lib/api/api.rb b/lib/api/api.rb index 6f63d8998be..9bef9d826b3 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -189,6 +189,7 @@ module API mount ::API::Release::Links mount ::API::ResourceAccessTokens mount ::API::SnippetRepositoryStorageMoves + mount ::API::ProtectedBranches mount ::API::Statistics mount ::API::Suggestions mount ::API::Tags @@ -296,7 +297,6 @@ module API mount ::API::ProjectStatistics mount ::API::ProjectTemplates mount ::API::Projects - mount ::API::ProtectedBranches mount ::API::ProtectedTags mount ::API::PypiPackages mount ::API::Releases diff --git a/lib/api/entities/protected_branch.rb b/lib/api/entities/protected_branch.rb index ac44d06e69c..42f721b40a6 100644 --- a/lib/api/entities/protected_branch.rb +++ b/lib/api/entities/protected_branch.rb @@ -3,11 +3,11 @@ module API module Entities class ProtectedBranch < Grape::Entity - expose :id - expose :name - expose :push_access_levels, using: Entities::ProtectedRefAccess - expose :merge_access_levels, using: Entities::ProtectedRefAccess - expose :allow_force_push + expose :id, documentation: { type: 'integer', example: 1 } + expose :name, documentation: { type: 'string', example: 'main' } + expose :push_access_levels, using: Entities::ProtectedRefAccess, documentation: { is_array: true } + expose :merge_access_levels, using: Entities::ProtectedRefAccess, documentation: { is_array: true } + expose :allow_force_push, documentation: { type: 'boolean' } end end end diff --git a/lib/api/entities/protected_ref_access.rb b/lib/api/entities/protected_ref_access.rb index 7b9b30d2385..ba28c724448 100644 --- a/lib/api/entities/protected_ref_access.rb +++ b/lib/api/entities/protected_ref_access.rb @@ -3,11 +3,12 @@ module API module Entities class ProtectedRefAccess < Grape::Entity - expose :id - expose :access_level - expose :access_level_description do |protected_ref_access| - protected_ref_access.humanize - end + expose :id, documentation: { type: 'integer', example: 1 } + expose :access_level, documentation: { type: 'integer', example: 40 } + expose :access_level_description, + documentation: { type: 'string', example: 'Maintainers' } do |protected_ref_access| + protected_ref_access.humanize + end end end end diff --git a/lib/api/protected_branches.rb b/lib/api/protected_branches.rb index 27fbee0ab65..443b3e90dc3 100644 --- a/lib/api/protected_branches.rb +++ b/lib/api/protected_branches.rb @@ -13,15 +13,20 @@ module API helpers Helpers::ProtectedBranchesHelpers params do - requires :id, type: String, desc: 'The ID of a project' + requires :id, type: String, desc: 'The ID of a project', documentation: { example: 'gitlab-org/gitlab' } end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc "Get a project's protected branches" do - success Entities::ProtectedBranch + success code: 200, model: Entities::ProtectedBranch + is_array true + failure [ + { code: 404, message: '404 Project Not Found' }, + { code: 401, message: '401 Unauthorized' } + ] end params do use :pagination - optional :search, type: String, desc: 'Search for a protected branch by name' + optional :search, type: String, desc: 'Search for a protected branch by name', documentation: { example: 'mai' } end # rubocop: disable CodeReuse/ActiveRecord get ':id/protected_branches' do @@ -36,10 +41,14 @@ module API # rubocop: enable CodeReuse/ActiveRecord desc 'Get a single protected branch' do - success Entities::ProtectedBranch + success code: 200, model: Entities::ProtectedBranch + failure [ + { code: 404, message: '404 Project Not Found' }, + { code: 401, message: '401 Unauthorized' } + ] end params do - requires :name, type: String, desc: 'The name of the branch or wildcard' + requires :name, type: String, desc: 'The name of the branch or wildcard', documentation: { example: 'main' } end # rubocop: disable CodeReuse/ActiveRecord get ':id/protected_branches/:name', requirements: BRANCH_ENDPOINT_REQUIREMENTS do @@ -50,10 +59,16 @@ module API # rubocop: enable CodeReuse/ActiveRecord desc 'Protect a single branch' do - success Entities::ProtectedBranch + success code: 201, model: Entities::ProtectedBranch + failure [ + { code: 422, message: 'name is missing' }, + { code: 409, message: "Protected branch 'main' already exists" }, + { code: 404, message: '404 Project Not Found' }, + { code: 401, message: '401 Unauthorized' } + ] end params do - requires :name, type: String, desc: 'The name of the protected branch' + requires :name, type: String, desc: 'The name of the protected branch', documentation: { example: 'main' } optional :push_access_level, type: Integer, values: ProtectedBranch::PushAccessLevel.allowed_access_levels, desc: 'Access levels allowed to push (defaults: `40`, maintainer access level)' @@ -87,10 +102,15 @@ module API # rubocop: enable CodeReuse/ActiveRecord desc 'Update a protected branch' do - success ::API::Entities::ProtectedBranch + success code: 200, model: Entities::ProtectedBranch + failure [ + { code: 422, message: 'Push access levels access level has already been taken' }, + { code: 404, message: '404 Project Not Found' }, + { code: 401, message: '401 Unauthorized' } + ] end params do - requires :name, type: String, desc: 'The name of the branch' + requires :name, type: String, desc: 'The name of the branch', documentation: { example: 'main' } optional :allow_force_push, type: Boolean, desc: 'Allow force push for all users with push access.' @@ -114,7 +134,14 @@ module API desc 'Unprotect a single branch' params do - requires :name, type: String, desc: 'The name of the protected branch' + requires :name, type: String, desc: 'The name of the protected branch', documentation: { example: 'main' } + end + desc 'Unprotect a single branch' do + success code: 204 + failure [ + { code: 404, message: '404 Project Not Found' }, + { code: 401, message: '401 Unauthorized' } + ] end # rubocop: disable CodeReuse/ActiveRecord delete ':id/protected_branches/:name', requirements: BRANCH_ENDPOINT_REQUIREMENTS, urgency: :low do diff --git a/spec/factories/users/ghost_user_migrations.rb b/spec/factories/users/ghost_user_migrations.rb index 0fe7cded4f3..77b7f7e6df4 100644 --- a/spec/factories/users/ghost_user_migrations.rb +++ b/spec/factories/users/ghost_user_migrations.rb @@ -5,5 +5,6 @@ FactoryBot.define do association :user initiator_user { association(:user) } hard_delete { false } + consume_after { Time.current } end end diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index cbe11c20798..c7420ca9c48 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -5,6 +5,8 @@ import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import noteActions from '~/notes/components/note_actions.vue'; +import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants'; +import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue'; import createStore from '~/notes/stores'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { userDataMock } from '../mock_data'; @@ -18,6 +20,23 @@ describe('noteActions', () => { const findUserAccessRoleBadge = (idx) => wrapper.findAllComponents(UserAccessRoleBadge).at(idx); const findUserAccessRoleBadgeText = (idx) => findUserAccessRoleBadge(idx).text().trim(); + const findTimelineButton = () => wrapper.findComponent(TimelineEventButton); + + const setupStoreForIncidentTimelineEvents = ({ + userCanAdd, + noteableType, + isPromotionInProgress = true, + }) => { + store.dispatch('setUserData', { + ...userDataMock, + can_add_timeline_events: userCanAdd, + }); + store.state.noteableData = { + ...store.state.noteableData, + type: noteableType, + }; + store.state.isPromoteCommentToTimelineEventInProgress = isPromotionInProgress; + }; const mountNoteActions = (propsData, computed) => { return mount(noteActions, { @@ -238,7 +257,8 @@ describe('noteActions', () => { describe('user is not logged in', () => { beforeEach(() => { - store.dispatch('setUserData', {}); + // userData can be null https://gitlab.com/gitlab-org/gitlab/-/issues/379375 + store.dispatch('setUserData', null); wrapper = mountNoteActions({ ...props, canDelete: false, @@ -301,4 +321,56 @@ describe('noteActions', () => { expect(resolveButton.attributes('title')).toBe('Thread stays unresolved'); }); }); + + describe('timeline event button', () => { + // why: We are working with an integrated store, so let's imply the getter is used + describe.each` + desc | userCanAdd | noteableType | exists + ${'default'} | ${true} | ${NOTEABLE_TYPE_MAPPING.Incident} | ${true} + ${'when cannot add incident timeline event'} | ${false} | ${NOTEABLE_TYPE_MAPPING.Incident} | ${false} + ${'when is not incident'} | ${true} | ${NOTEABLE_TYPE_MAPPING.MergeRequest} | ${false} + `('$desc', ({ userCanAdd, noteableType, exists }) => { + beforeEach(() => { + setupStoreForIncidentTimelineEvents({ + userCanAdd, + noteableType, + }); + + wrapper = mountNoteActions({ ...props }); + }); + + it(`handles rendering of timeline button (exists=${exists})`, () => { + expect(findTimelineButton().exists()).toBe(exists); + }); + }); + + describe('default', () => { + beforeEach(() => { + setupStoreForIncidentTimelineEvents({ + userCanAdd: true, + noteableType: NOTEABLE_TYPE_MAPPING.Incident, + }); + + wrapper = mountNoteActions({ ...props }); + }); + + it('should render timeline-event-button', () => { + expect(findTimelineButton().props()).toEqual({ + noteId: props.noteId, + isPromotionInProgress: true, + }); + }); + + it('when timeline-event-button emits click-promote-comment-to-event, dispatches action', () => { + jest.spyOn(store, 'dispatch').mockImplementation(); + + expect(store.dispatch).not.toHaveBeenCalled(); + + findTimelineButton().vm.$emit('click-promote-comment-to-event'); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith('promoteCommentToTimelineEvent'); + }); + }); + }); }); diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index e03fa854e54..1514602d424 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -1,5 +1,5 @@ import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; -import { DESC, ASC } from '~/notes/constants'; +import { DESC, ASC, NOTEABLE_TYPE_MAPPING } from '~/notes/constants'; import * as getters from '~/notes/stores/getters'; import { notesDataMock, @@ -536,4 +536,24 @@ describe('Getters Notes Store', () => { expect(getters.sortDirection(state)).toBe(DESC); }); }); + + describe('canUserAddIncidentTimelineEvents', () => { + it.each` + userData | noteableData | expected + ${{ can_add_timeline_events: true }} | ${{ type: NOTEABLE_TYPE_MAPPING.Incident }} | ${true} + ${{ can_add_timeline_events: true }} | ${{ type: NOTEABLE_TYPE_MAPPING.Issue }} | ${false} + ${null} | ${{ type: NOTEABLE_TYPE_MAPPING.Incident }} | ${false} + ${{ can_add_timeline_events: false }} | ${{ type: NOTEABLE_TYPE_MAPPING.Incident }} | ${false} + `( + 'with userData=$userData and noteableData=$noteableData, expected=$expected', + ({ userData, noteableData, expected }) => { + Object.assign(state, { + userData, + noteableData, + }); + + expect(getters.canUserAddIncidentTimelineEvents(state)).toBe(expected); + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js index 375b1307616..032f2985d35 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js @@ -1,10 +1,17 @@ import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import godepsJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'; import gemspecLinker from '~/vue_shared/components/source_viewer/plugins/utils/gemspec_linker'; import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies'; -import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT, GEMSPEC_FILE_TYPE } from './mock_data'; +import { + PACKAGE_JSON_FILE_TYPE, + PACKAGE_JSON_CONTENT, + GEMSPEC_FILE_TYPE, + GODEPS_JSON_FILE_TYPE, +} from './mock_data'; jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'); jest.mock('~/vue_shared/components/source_viewer/plugins/utils/gemspec_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'); describe('Highlight.js plugin for linking dependencies', () => { const hljsResultMock = { value: 'test' }; @@ -18,4 +25,9 @@ describe('Highlight.js plugin for linking dependencies', () => { linkDependencies(hljsResultMock, GEMSPEC_FILE_TYPE); expect(gemspecLinker).toHaveBeenCalled(); }); + + it('calls godepsJsonLinker for godeps_json file types', () => { + linkDependencies(hljsResultMock, GODEPS_JSON_FILE_TYPE); + expect(godepsJsonLinker).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js index aa874c9c081..3146600e491 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js @@ -2,3 +2,5 @@ export const PACKAGE_JSON_FILE_TYPE = 'package_json'; export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }'; export const GEMSPEC_FILE_TYPE = 'gemspec'; + +export const GODEPS_JSON_FILE_TYPE = 'godeps_json'; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js new file mode 100644 index 00000000000..1d8b11cf707 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js @@ -0,0 +1,27 @@ +import godepsJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'; + +const getInputValue = (dependencyString) => + `"ImportPath": "${dependencyString}"`; +const getOutputValue = (dependencyString, expectedHref) => + `"ImportPath": "${dependencyString}"`; + +describe('Highlight.js plugin for linking Godeps.json dependencies', () => { + it.each` + dependency | expectedHref + ${'gitlab.com/group/project/path'} | ${'https://gitlab.com/group/project/_/tree/master/path'} + ${'gitlab.com/group/subgroup/project.git/path'} | ${'https://gitlab.com/group/subgroup/_/tree/master/project.git/path'} + ${'github.com/docker/docker/pkg/homedir'} | ${'https://github.com/docker/docker/tree/master/pkg/homedir'} + ${'golang.org/x/net/http2'} | ${'https://godoc.org/golang.org/x/net/http2'} + ${'gopkg.in/yaml.v1'} | ${'https://gopkg.in/yaml.v1'} + `( + 'mutates the input value by wrapping dependency names in anchors and altering path when needed', + ({ dependency, expectedHref }) => { + const inputValue = getInputValue(dependency); + const outputValue = getOutputValue(dependency, expectedHref); + const hljsResultMock = { value: inputValue }; + + const output = godepsJsonLinker(hljsResultMock); + expect(output).toBe(outputValue); + }, + ); +}); diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 1d990bed907..04e91547c8e 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -1078,10 +1078,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end context 'snowplow stats' do - before do - stub_feature_flags(usage_data_instrumentation: false) - end - it 'gathers snowplow stats' do expect(subject[:settings][:snowplow_enabled]).to eq(Gitlab::CurrentSettings.snowplow_enabled?) expect(subject[:settings][:snowplow_configured_to_gitlab_collector]).to eq(snowplow_gitlab_host?) diff --git a/spec/models/concerns/ci/partitionable/switch_spec.rb b/spec/models/concerns/ci/partitionable/switch_spec.rb index 09005489268..d955ad223f8 100644 --- a/spec/models/concerns/ci/partitionable/switch_spec.rb +++ b/spec/models/concerns/ci/partitionable/switch_spec.rb @@ -264,6 +264,28 @@ RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do end end + context 'with safe request store', :request_store do + it 'changing the flag to true does not affect the current request' do + stub_feature_flags(table_rollout_flag => false) + + expect(model.table_name).to eq('_test_ci_jobs_metadata') + + stub_feature_flags(table_rollout_flag => true) + + expect(model.table_name).to eq('_test_ci_jobs_metadata') + end + + it 'changing the flag to false does not affect the current request' do + stub_feature_flags(table_rollout_flag => true) + + expect(model.table_name).to eq('_test_p_ci_jobs_metadata') + + stub_feature_flags(table_rollout_flag => false) + + expect(model.table_name).to eq('_test_p_ci_jobs_metadata') + end + end + def rollout_and_rollback_flag(old, new) # Load class and SQL statements cache old.call diff --git a/spec/models/users/ghost_user_migration_spec.rb b/spec/models/users/ghost_user_migration_spec.rb index d4a0657c3be..a0b2af6175a 100644 --- a/spec/models/users/ghost_user_migration_spec.rb +++ b/spec/models/users/ghost_user_migration_spec.rb @@ -8,7 +8,18 @@ RSpec.describe Users::GhostUserMigration do it { is_expected.to belong_to(:initiator_user) } end - describe 'validation' do + describe 'validations' do it { is_expected.to validate_presence_of(:user_id) } end + + describe 'scopes' do + describe '.consume_order' do + let!(:ghost_user_migration_1) { create(:ghost_user_migration, consume_after: Time.current) } + let!(:ghost_user_migration_2) { create(:ghost_user_migration, consume_after: 5.minutes.ago) } + + subject { described_class.consume_order.to_a } + + it { is_expected.to eq([ghost_user_migration_2, ghost_user_migration_1]) } + end + end end diff --git a/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb b/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb index 7366b1646b9..107ff82016c 100644 --- a/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb +++ b/spec/services/users/migrate_records_to_ghost_user_in_batches_service_spec.rb @@ -27,5 +27,34 @@ RSpec.describe Users::MigrateRecordsToGhostUserInBatchesService do service.execute end + + it 'process jobs ordered by the consume_after timestamp' do + older_ghost_user_migration = create(:ghost_user_migration, user: create(:user), + consume_after: 5.minutes.ago) + + # setup execution tracker to only allow a single job to be processed + allow_next_instance_of(::Gitlab::Utils::ExecutionTracker) do |tracker| + allow(tracker).to receive(:over_limit?).and_return(false, true) + end + + expect(Users::MigrateRecordsToGhostUserService).to( + receive(:new).with(older_ghost_user_migration.user, + older_ghost_user_migration.initiator_user, + any_args) + ).and_call_original + + service.execute + end + + it 'reschedules job in case of an error', :freeze_time do + expect_next_instance_of(Users::MigrateRecordsToGhostUserService) do |service| + expect(service).to(receive(:execute)).and_raise(ActiveRecord::QueryCanceled) + end + expect(Gitlab::ErrorTracking).to receive(:track_exception) + + expect { service.execute }.to( + change { ghost_user_migration.reload.consume_after } + .to(30.minutes.from_now)) + end end end