diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index d356e1685722e3b4a4111567f3b41f673eb5b47e..7e21e3617f4d99f927db0a663f03702e994ac338 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -61,6 +61,7 @@ def preloads commit_count: [:metrics], diff_stats_summary: [:metrics], approved_by: [:approved_by_users], + merge_after: [:merge_schedule], milestone: [:milestone], security_auto_fix: [:author], head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }], diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 8daef281b603aa50dbd17496608ff5a41968fbef..e261b55300f069a836ac5f4167d3a39824be2898 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -100,6 +100,11 @@ class MergeRequestType < BaseObject method: :public_merge_status, null: true, description: 'Merge status of the merge request.' + field :merge_after, ::Types::TimeType, + null: true, + description: 'Date after which the merge request can be merged.', + alpha: { milestone: '17.4' } + field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, null: true, calls_gitaly: true, description: 'Detailed merge status of the merge request.' @@ -347,6 +352,10 @@ def merge_user object.metrics&.merged_by || object.merge_user end + def merge_after + object.merge_schedule&.merge_after + end + def detailed_merge_status ::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 93ec04308668089865565dd2822374168490aedb..699af3ec43cd2528e1a40321b679ab80acc458e6 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -66,6 +66,8 @@ class MergeRequest < ApplicationRecord has_one :predictions, inverse_of: :merge_request delegate :suggested_reviewers, to: :predictions + has_one :merge_schedule, class_name: 'MergeRequests::MergeSchedule', inverse_of: :merge_request + belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' manual_inverse_association :latest_merge_request_diff, :merge_request @@ -378,6 +380,7 @@ def public_merge_status preload_routables.preload( :assignees, :author, :unresolved_notes, :labels, :milestone, :timelogs, :latest_merge_request_diff, :reviewers, + :merge_schedule, target_project: :project_feature, metrics: [:latest_closed_by, :merged_by] ) diff --git a/app/models/merge_requests/merge_schedule.rb b/app/models/merge_requests/merge_schedule.rb new file mode 100644 index 0000000000000000000000000000000000000000..5749768b5a10d8368d1c1df0634091013001a19e --- /dev/null +++ b/app/models/merge_requests/merge_schedule.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeSchedule < ApplicationRecord + self.table_name = 'merge_request_merge_schedules' + + belongs_to :merge_request, optional: false, inverse_of: :merge_schedule + + before_validation :set_sharding_key + + def set_sharding_key + self.project_id = merge_request&.target_project&.id + end + end +end diff --git a/db/docs/merge_request_merge_schedules.yml b/db/docs/merge_request_merge_schedules.yml new file mode 100644 index 0000000000000000000000000000000000000000..7923501b06b70fd65c34cd93a21a04645fb5e997 --- /dev/null +++ b/db/docs/merge_request_merge_schedules.yml @@ -0,0 +1,12 @@ +--- +table_name: merge_request_merge_schedules +classes: +- MergeRequests::MergeSchedule +feature_categories: +- code_review_workflow +description: Stores timestamps for scheduled merges +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165092 +milestone: '17.4' +gitlab_schema: gitlab_main_cell +sharding_key: + project_id: projects diff --git a/db/migrate/20240911181854_create_merge_request_merge_schedules.rb b/db/migrate/20240911181854_create_merge_request_merge_schedules.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c694ceead3ec72fcf6ff6ff76dac257a154bbbb --- /dev/null +++ b/db/migrate/20240911181854_create_merge_request_merge_schedules.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateMergeRequestMergeSchedules < Gitlab::Database::Migration[2.2] + milestone '17.4' + + def change + create_table :merge_request_merge_schedules do |t| # rubocop:disable Migration/EnsureFactoryForTable -- factory exists in spec/factories/merge_request_merge_schedule.rb + t.references :merge_request, foreign_key: { on_delete: :cascade }, index: false, null: false + t.datetime_with_timezone :merge_after + + t.bigint :project_id, null: false + + t.index :merge_request_id, unique: true + t.index :project_id + end + end +end diff --git a/db/migrate/20240911181855_create_merge_request_merge_schedules_sharding_key_fk.rb b/db/migrate/20240911181855_create_merge_request_merge_schedules_sharding_key_fk.rb new file mode 100644 index 0000000000000000000000000000000000000000..143248ee3c256257b41a8e681a8a64ac18bc89dd --- /dev/null +++ b/db/migrate/20240911181855_create_merge_request_merge_schedules_sharding_key_fk.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateMergeRequestMergeSchedulesShardingKeyFk < Gitlab::Database::Migration[2.2] + milestone '17.4' + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :merge_request_merge_schedules, :projects, column: :project_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :merge_request_merge_schedules, column: :project_id + end + end +end diff --git a/db/schema_migrations/20240911181854 b/db/schema_migrations/20240911181854 new file mode 100644 index 0000000000000000000000000000000000000000..75e2bbe9721c9af32eebb040835ee346c618b2bd --- /dev/null +++ b/db/schema_migrations/20240911181854 @@ -0,0 +1 @@ +6dd9484b5c7d61943dae2ed684c8d84d0c0319e009c26d1bcbfcbe0ae69939e7 \ No newline at end of file diff --git a/db/schema_migrations/20240911181855 b/db/schema_migrations/20240911181855 new file mode 100644 index 0000000000000000000000000000000000000000..b3cf9c49b4c858afcb4ff96c6c63cbc6fa97d5db --- /dev/null +++ b/db/schema_migrations/20240911181855 @@ -0,0 +1 @@ +4e56708b920c40ba9c6050f2441f313715e3db134ad6114fb9091829506736c1 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 54a815ca16c76e1da0f08c9833fa2e0ceeee022e..d9b5da79c3194950aa6485ece6a8a810232edee3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13105,6 +13105,22 @@ CREATE SEQUENCE merge_request_diffs_id_seq ALTER SEQUENCE merge_request_diffs_id_seq OWNED BY merge_request_diffs.id; +CREATE TABLE merge_request_merge_schedules ( + id bigint NOT NULL, + merge_request_id bigint NOT NULL, + merge_after timestamp with time zone, + project_id bigint NOT NULL +); + +CREATE SEQUENCE merge_request_merge_schedules_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE merge_request_merge_schedules_id_seq OWNED BY merge_request_merge_schedules.id; + CREATE TABLE merge_request_metrics ( merge_request_id bigint NOT NULL, latest_build_started_at timestamp without time zone, @@ -21872,6 +21888,8 @@ ALTER TABLE ONLY merge_request_diff_details ALTER COLUMN merge_request_diff_id S ALTER TABLE ONLY merge_request_diffs ALTER COLUMN id SET DEFAULT nextval('merge_request_diffs_id_seq'::regclass); +ALTER TABLE ONLY merge_request_merge_schedules ALTER COLUMN id SET DEFAULT nextval('merge_request_merge_schedules_id_seq'::regclass); + ALTER TABLE ONLY merge_request_metrics ALTER COLUMN id SET DEFAULT nextval('merge_request_metrics_id_seq'::regclass); ALTER TABLE ONLY merge_request_predictions ALTER COLUMN merge_request_id SET DEFAULT nextval('merge_request_predictions_merge_request_id_seq'::regclass); @@ -24213,6 +24231,9 @@ ALTER TABLE ONLY merge_request_diff_files ALTER TABLE ONLY merge_request_diffs ADD CONSTRAINT merge_request_diffs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY merge_request_merge_schedules + ADD CONSTRAINT merge_request_merge_schedules_pkey PRIMARY KEY (id); + ALTER TABLE ONLY merge_request_metrics ADD CONSTRAINT merge_request_metrics_pkey PRIMARY KEY (id); @@ -28900,6 +28921,10 @@ CREATE INDEX index_merge_request_diffs_on_project_id ON merge_request_diffs USIN CREATE UNIQUE INDEX index_merge_request_diffs_on_unique_merge_request_id ON merge_request_diffs USING btree (merge_request_id) WHERE (diff_type = 2); +CREATE UNIQUE INDEX index_merge_request_merge_schedules_on_merge_request_id ON merge_request_merge_schedules USING btree (merge_request_id); + +CREATE INDEX index_merge_request_merge_schedules_on_project_id ON merge_request_merge_schedules USING btree (project_id); + CREATE INDEX index_merge_request_metrics_on_first_deployed_to_production_at ON merge_request_metrics USING btree (first_deployed_to_production_at); CREATE INDEX index_merge_request_metrics_on_latest_closed_at ON merge_request_metrics USING btree (latest_closed_at) WHERE (latest_closed_at IS NOT NULL); @@ -34008,6 +34033,9 @@ ALTER TABLE ONLY operations_strategies ALTER TABLE ONLY lfs_objects_projects ADD CONSTRAINT fk_a56e02279c FOREIGN KEY (lfs_object_id) REFERENCES lfs_objects(id) ON DELETE RESTRICT NOT VALID; +ALTER TABLE ONLY merge_request_merge_schedules + ADD CONSTRAINT fk_a5ff9339a9 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY merge_requests ADD CONSTRAINT fk_a6963e8447 FOREIGN KEY (target_project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -35214,6 +35242,9 @@ ALTER TABLE zoekt_tasks ALTER TABLE ONLY ml_models ADD CONSTRAINT fk_rails_51e87f7c50 FOREIGN KEY (project_id) REFERENCES projects(id); +ALTER TABLE ONLY merge_request_merge_schedules + ADD CONSTRAINT fk_rails_5294434bc3 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE; + ALTER TABLE ONLY elastic_group_index_statuses ADD CONSTRAINT fk_rails_52b9969b12 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 779de53af3077c0198942d230f6e1b5a5d9ddc05..02a1652ddf21cb7cef44e336cab4796d4b348256 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -25494,6 +25494,7 @@ Defines which user roles, users, or groups can merge into a protected branch. | `iid` | [`String!`](#string) | Internal ID of the merge request. | | `inProgressMergeCommitSha` | [`String`](#string) | Commit SHA of the merge request if merge is in progress. | | `labels` | [`LabelConnection`](#labelconnection) | Labels of the merge request. (see [Connections](#connections)) | +| `mergeAfter` **{warning-solid}** | [`Time`](#time) | **Introduced** in GitLab 17.4. **Status**: Experiment. Date after which the merge request can be merged. | | `mergeCommitSha` | [`String`](#string) | SHA of the merge request commit (set once merged). | | `mergeError` | [`String`](#string) | Error message due to a merge error. | | `mergeOngoing` | [`Boolean!`](#boolean) | Indicates if a merge is currently occurring. | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 9aa9bf6fe5d3dea8d912719ac396fc7c9f6ee23e..c8f86761561c8b4d1e718bd9ef08d602c58f51cc 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -17,6 +17,7 @@ DETAILS: > - `with_merge_status_recheck` [changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115948) in GitLab 15.11 [with a flag](../administration/feature_flags.md) named `restrict_merge_status_recheck` to be ignored for requests from users insufficient permissions. Disabled by default. > - `approvals_before_merge` [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119503) in GitLab 16.0. > - `prepared_at` [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122001) in GitLab 16.1. +> - `merge_after` [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165092) in GitLab 17.4. All API calls to non-public information require authentication. @@ -118,6 +119,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -367,6 +369,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -553,6 +556,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -747,6 +751,7 @@ Example response: "merged_by": null, // Deprecated and will be removed in API v5. Use `merge_user` instead. "merge_user": null, "merged_at": null, + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -1134,6 +1139,7 @@ Example response: "merged_by": null, "merge_user": null, "merged_at": null, + "merge_after": "2018-09-07T11:16:00.000Z", "closed_by": null, "closed_at": null, "target_branch": "master", @@ -1243,6 +1249,7 @@ Example response: "merged_by": null, "merge_user": null, "merged_at": null, + "merge_after": "2018-09-07T11:16:00.000Z", "closed_by": null, "closed_at": null, "target_branch": "master", @@ -1787,6 +1794,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -1960,6 +1968,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -2153,6 +2162,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -2354,6 +2364,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -2742,6 +2753,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, @@ -2906,6 +2918,7 @@ Example response: "web_url": "https://gitlab.com/DouweM" }, "merged_at": "2018-09-07T11:16:17.520Z", + "merge_after": "2018-09-07T11:16:00.000Z", "prepared_at": "2018-09-04T11:16:17.520Z", "closed_by": null, "closed_at": null, diff --git a/lib/api/entities/merge_request_basic.rb b/lib/api/entities/merge_request_basic.rb index fce85ff1c49b851eb514d630e32a92602cded076..4348d292eb4b1a051b042370480b5148cd67a605 100644 --- a/lib/api/entities/merge_request_basic.rb +++ b/lib/api/entities/merge_request_basic.rb @@ -64,6 +64,11 @@ class MergeRequestBasic < IssuableEntity merge_request.public_merge_status end expose :detailed_merge_status + + expose :merge_after do |merge_request, _options| + merge_request.merge_schedule&.merge_after + end + expose :diff_head_sha, as: :sha expose :merge_commit_sha expose :squash_commit_sha diff --git a/lib/gitlab/import_export/base/relation_factory.rb b/lib/gitlab/import_export/base/relation_factory.rb index a48d282307416a77f6e2750f9117db85d15cefec..952aef5625548f765fedf3c5ea0acdfbd22e2d84 100644 --- a/lib/gitlab/import_export/base/relation_factory.rb +++ b/lib/gitlab/import_export/base/relation_factory.rb @@ -9,7 +9,7 @@ class RelationFactory IMPORTED_OBJECT_MAX_RETRIES = 5 - OVERRIDES = { user_contributions: :user }.freeze + OVERRIDES = { user_contributions: :user, merge_schedule: 'MergeRequests::MergeSchedule' }.freeze EXISTING_OBJECT_RELATIONS = %i[].freeze # This represents all relations that have unique key on `project_id` or `group_id` diff --git a/spec/factories/merge_request_merge_schedule.rb b/spec/factories/merge_request_merge_schedule.rb new file mode 100644 index 0000000000000000000000000000000000000000..2be331efe1591202ff342cf1443eb3c14867db73 --- /dev/null +++ b/spec/factories/merge_request_merge_schedule.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :merge_request_merge_schedule, class: 'MergeRequests::MergeSchedule' do + merge_request + end +end diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index ebd3cd5f4fceec5718d2678126afcd3c15e5cbfe..773565955f7d4caacbe0b102cda25e235e3d592f 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -37,7 +37,7 @@ squash_on_merge available_auto_merge_strategies has_ci mergeable commits committers commits_without_merge_commits squash security_auto_fix default_squash_commit_message auto_merge_strategy merge_user award_emoji prepared_at codequality_reports_comparer supports_lock_on_merge - mergeability_checks + mergeability_checks merge_after allows_multiple_assignees allows_multiple_reviewers retargeted name ] diff --git a/spec/lib/api/entities/merge_request_basic_spec.rb b/spec/lib/api/entities/merge_request_basic_spec.rb index 09f064c4cdd001f024051b4f125a1003bbfd19b5..d0a056547f7b8f1134c416d50c7e3b458672c076 100644 --- a/spec/lib/api/entities/merge_request_basic_spec.rb +++ b/spec/lib/api/entities/merge_request_basic_spec.rb @@ -20,7 +20,7 @@ def present(obj) expected_fields = %i[ merged_by merge_user merged_at closed_by closed_at target_branch user_notes_count upvotes downvotes author assignees assignee reviewers source_project_id target_project_id labels draft work_in_progress - milestone merge_when_pipeline_succeeds merge_status detailed_merge_status sha merge_commit_sha + milestone merge_when_pipeline_succeeds merge_status detailed_merge_status merge_after sha merge_commit_sha squash_commit_sha discussion_locked should_remove_source_branch force_remove_source_branch prepared_at reference references web_url time_stats squash task_completion_status has_conflicts blocking_discussions_resolved imported imported_from diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index baa29e4e3ce681dbe152d834a1ee2f04a9714e0b..246feda51d53247664627cc428652b7af30cf53b 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -227,6 +227,7 @@ merge_requests: - merge_request_context_commits - merge_request_context_commit_diff_files - merge_request_stage_events +- merge_schedule - events - merge_requests_closing_issues - cached_closes_issues @@ -293,6 +294,8 @@ merge_request_diff_files: merge_request_context_commits: - merge_request - diff_files +merge_schedule: +- merge_request cleanup_schedule: - merge_request ci_pipelines: diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8ef7220667569bbaa4e32c07f8d9a433b423342a..2fab1f07887caf431e16ff6a095a8b7676fbfde4 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -36,6 +36,7 @@ it { is_expected.to have_many(:reviews).inverse_of(:merge_request) } it { is_expected.to have_many(:reviewed_by_users).through(:reviews).source(:author) } it { is_expected.to have_one(:cleanup_schedule).inverse_of(:merge_request) } + it { is_expected.to have_one(:merge_schedule).class_name('MergeRequests::MergeSchedule').inverse_of(:merge_request) } it { is_expected.to have_many(:created_environments).class_name('Environment').inverse_of(:merge_request) } it { is_expected.to have_many(:assignment_events).class_name('ResourceEvents::MergeRequestAssignmentEvent').inverse_of(:merge_request) } diff --git a/spec/models/merge_requests/merge_schedule_spec.rb b/spec/models/merge_requests/merge_schedule_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a869032f118cb320b299d817522e448138812403 --- /dev/null +++ b/spec/models/merge_requests/merge_schedule_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::MergeSchedule, feature_category: :code_review_workflow do + subject { create(:merge_request_merge_schedule) } + + describe 'associations' do + it { is_expected.to belong_to(:merge_request).required } + end + + describe 'callbacks' do + let(:merge_request) { create(:merge_request) } + let(:schedule) do + create(:merge_request_merge_schedule, merge_request: merge_request, + project_id: merge_request.target_project.id + 1) + end + + it 'overrides project_id to the correct sharding key' do + expect(schedule.project_id).to eq(merge_request.target_project.id) + end + end +end