From c806ec28fb4779150b23eac6de75c3d63f62d2c3 Mon Sep 17 00:00:00 2001 From: Emma Park Date: Sat, 3 May 2025 14:08:38 +0900 Subject: [PATCH 1/6] Bypass permission check to close MRs for archived projects When a fork relationship is removed from an archived project, existing MRs remain open due to the permission check (can? :update_merge_request) failing in read-only state. This change bypasses the check when the target project is archived, allowing the MRs to be safely closed. The behavior is gated behind the destroy_fork_network_on_archive feature flag to ensure controlled rollout. This ensures we can archive the project first and then unlink forks without leaving orphaned open MRs. Changelog: changed --- app/services/merge_requests/close_service.rb | 8 +++- app/services/projects/unlink_fork_service.rb | 6 ++- app/services/projects/update_service.rb | 6 +++ .../destroy_fork_network_on_archive.yml | 9 ++++ .../merge_requests/close_service_spec.rb | 43 ++++++++++++++++++ .../projects/unlink_fork_service_spec.rb | 15 ++++--- spec/services/projects/update_service_spec.rb | 44 +++++++++++++++++++ 7 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 42f5a8fd7ba217..fa186402aabbac 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -5,7 +5,13 @@ class CloseService < MergeRequests::BaseService include RemovesRefs def execute(merge_request, commit = nil) - return merge_request unless can?(current_user, :update_merge_request, merge_request) + if Feature.enabled?(:destroy_fork_network_on_archive, project) + unless params[:skip_authorization].present? || can?(current_user, :update_merge_request, merge_request) + return merge_request + end + else + return merge_request unless can?(current_user, :update_merge_request, merge_request) + end # If we close MergeRequest we want to ignore validation # so we can close broken one (Ex. fork project removed) diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 89950c15e1231c..c9fa21faffcf6b 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -17,7 +17,11 @@ def execute(refresh_statistics: true) .from_and_to_forks(@project) merge_requests.find_each do |mr| - ::MergeRequests::CloseService.new(project: @project, current_user: @current_user).execute(mr) + ::MergeRequests::CloseService.new( + project: @project, + current_user: @current_user, + params: { skip_authorization: true } + ).execute(mr) log_info(message: "UnlinkForkService: Closed merge request", merge_request_id: mr.id) end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 94cd8ad8f80ce8..edaaecee4877fc 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -37,6 +37,12 @@ def execute if project.update(params.except(*non_assignable_project_params)) after_update + if Feature.enabled?(:destroy_fork_network_on_archive, project) && + project.previous_changes[:archived] == [false, true] + + UnlinkForkService.new(project, current_user).execute + end + success else update_failed! diff --git a/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml b/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml new file mode 100644 index 00000000000000..07751de21e6680 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml @@ -0,0 +1,9 @@ +--- +name: destroy_fork_network_on_archive +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/518708 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189438 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/538925 +milestone: '18.00' +type: gitlab_com_derisk +group: group::source code +default_enabled: false \ No newline at end of file diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index c2c27ff206ed24..559b8faf94c4a4 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -143,5 +143,48 @@ def execute execute end end + + context 'with skip_authorization param' do + let(:service_with_skip) do + described_class.new( + project: project, + current_user: user, + params: { skip_authorization: true } + ) + end + + let(:service_without_skip) { described_class.new(project: project, current_user: guest) } + + it 'closes MR if skip_authorization param is present' do + result = service_with_skip.execute(merge_request) + + expect(result).to be_closed + end + + it 'does not close MR if skip_authorization param is not present and user has no permission' do + result = service_without_skip.execute(merge_request) + + expect(result).to be_open + end + + context 'with feature flag :destroy_fork_network_on_archive disabled' do + before do + stub_feature_flags(destroy_fork_network_on_archive: false) + end + + it 'closes MR if user has permission' do + result = service.execute(merge_request) + + expect(result).to be_closed + end + + it 'does not close MR if user lacks permission' do + guest_service = described_class.new(project: project, current_user: guest) + result = guest_service.execute(merge_request) + + expect(result).to be_open + end + end + end end end diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb index 5e8e042264ce9c..59a624496d7e09 100644 --- a/spec/services/projects/unlink_fork_service_spec.rb +++ b/spec/services/projects/unlink_fork_service_spec.rb @@ -16,11 +16,12 @@ let(:merge_request2) { create(:merge_request, source_project: forked_project, target_project: fork_project(project)) } let(:merge_request_in_fork) { create(:merge_request, source_project: forked_project, target_project: forked_project) } - let(:mr_close_service) { MergeRequests::CloseService.new(project: forked_project, current_user: user) } + let(:mr_close_service) { MergeRequests::CloseService.new(project: forked_project, current_user: user, params: { skip_authorization: skip_authorization }) } + let(:skip_authorization) { true } before do allow(MergeRequests::CloseService).to receive(:new) - .with(project: forked_project, current_user: user) + .with(project: forked_project, current_user: user, params: { skip_authorization: skip_authorization }) .and_return(mr_close_service) end @@ -91,11 +92,12 @@ let!(:merge_request2) { create(:merge_request, source_project: project, target_project: fork_project(project)) } let!(:merge_request_in_fork) { create(:merge_request, source_project: forked_project, target_project: forked_project) } - let(:mr_close_service) { MergeRequests::CloseService.new(project: project, current_user: user) } + let(:mr_close_service) { MergeRequests::CloseService.new(project: project, current_user: user, params: { skip_authorization: skip_authorization }) } + let(:skip_authorization) { true } before do allow(MergeRequests::CloseService).to receive(:new) - .with(project: project, current_user: user) + .with(project: project, current_user: user, params: { skip_authorization: skip_authorization }) .and_return(mr_close_service) end @@ -156,11 +158,12 @@ let!(:mr_from_child) { create(:merge_request, source_project: fork_of_fork, target_project: forked_project) } let!(:merge_request_in_fork) { create(:merge_request, source_project: forked_project, target_project: forked_project) } - let(:mr_close_service) { MergeRequests::CloseService.new(project: forked_project, current_user: user) } + let(:mr_close_service) { MergeRequests::CloseService.new(project: forked_project, current_user: user, params: { skip_authorization: skip_authorization }) } + let(:skip_authorization) { true } before do allow(MergeRequests::CloseService).to receive(:new) - .with(project: forked_project, current_user: user) + .with(project: forked_project, current_user: user, params: { skip_authorization: skip_authorization }) .and_return(mr_close_service) end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index b1ee0cef02369c..c4733bfce1eed9 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -513,6 +513,12 @@ end context 'when archiving a project' do + before do + allow(Projects::UnlinkForkService).to receive(:new).and_return(unlink_fork_service) + end + + let(:unlink_fork_service) { instance_double(Projects::UnlinkForkService, execute: true) } + it_behaves_like 'publishing Projects::ProjectAttributesChangedEvent', params: { archived: true }, attributes: %w[updated_at archived] @@ -526,6 +532,44 @@ root_namespace_id: project.root_namespace.id ) end + + context 'when feature flag is ON and project is being archived' do + it 'calls UnlinkForkService if project is being archived now' do + project.update!(archived: false) + + expect(Projects::UnlinkForkService).to receive(:new).with(project, user).and_return(unlink_fork_service) + + update_project(project, user, archived: true) + end + + it 'does not call UnlinkForkService if project was already archived' do + project.update!(archived: true) + + expect(Projects::UnlinkForkService).not_to receive(:new) + + update_project(project, user, archived: true) + end + end + + context 'when project is not being archived' do + it 'does not call UnlinkForkService' do + expect(Projects::UnlinkForkService).not_to receive(:new) + + update_project(project, user, archived: false) + end + end + + context 'when feature flag is OFF' do + before do + stub_feature_flags(destroy_fork_network_on_archive: false) + end + + it 'does not call UnlinkForkService' do + expect(Projects::UnlinkForkService).not_to receive(:new) + + update_project(project, user, archived: true) + end + end end context 'when changing operations feature visibility' do -- GitLab From ecaa9a501c76bf95c5a9b5e6ac2edc932370dc0c Mon Sep 17 00:00:00 2001 From: Emma Park Date: Sat, 3 May 2025 17:25:37 +0900 Subject: [PATCH 2/6] Clarify fork behavior when archiving a project --- doc/user/project/repository/forking_workflow.md | 5 ++++- doc/user/project/working_with_projects.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md index 3e873de8ebd758..b4ceb03ae3c6cc 100644 --- a/doc/user/project/repository/forking_workflow.md +++ b/doc/user/project/repository/forking_workflow.md @@ -28,6 +28,9 @@ Make your changes in your fork, then submit them through a merge request to the To create a [confidential merge request](../merge_requests/confidential.md), use a personal fork of a public repository. +**Note:** If the upstream project is archived, the fork relationship is automatically removed. +For more information, see [Archive a project](../working_with_projects.md#archive-a-project) + ## Create a fork {{< history >}} @@ -166,7 +169,7 @@ Prerequisites: {{< alert type="warning" >}} -If you remove a fork relationship, you can't send merge requests to the source. +If you remove a fork relationship, you can't send new merge requests to the source. Any existing open merge requests from the fork to the source are also closed. If anyone has forked your repository, their fork also loses the relationship. To restore the fork relationship, [use the API](../../../api/project_forks.md#create-a-fork-relationship-between-projects). diff --git a/doc/user/project/working_with_projects.md b/doc/user/project/working_with_projects.md index cedfadf6e6aa3d..71b53c57244743 100644 --- a/doc/user/project/working_with_projects.md +++ b/doc/user/project/working_with_projects.md @@ -424,6 +424,8 @@ These features are still accessible, but not writable. - Pull mirroring - All other project features +**Note:** When a project is archived, its fork relationship is removed and any open merge requests from forks targeting this project are automatically closed. + Active pipeline schedules of archived projects don't become read-only. If the project has deployed Pages, they are removed along with any custom domains, -- GitLab From d15db22dc0ae43d5296f4986c851241c3f6a0cb7 Mon Sep 17 00:00:00 2001 From: Emma Park Date: Sat, 3 May 2025 17:39:16 +0900 Subject: [PATCH 3/6] Add a newline at end of file --- .../gitlab_com_derisk/destroy_fork_network_on_archive.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml b/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml index 07751de21e6680..684900483a6e1d 100644 --- a/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml +++ b/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml @@ -6,4 +6,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/538925 milestone: '18.00' type: gitlab_com_derisk group: group::source code -default_enabled: false \ No newline at end of file +default_enabled: false -- GitLab From c5770a143c31088daa0841ada380e29414b38579 Mon Sep 17 00:00:00 2001 From: Emma Park Date: Thu, 8 May 2025 13:03:54 +0900 Subject: [PATCH 4/6] Reclassify destroy_fork_network_on_archive FF as a beta --- .../destroy_fork_network_on_archive.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename config/feature_flags/{gitlab_com_derisk => beta}/destroy_fork_network_on_archive.yml (87%) diff --git a/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml b/config/feature_flags/beta/destroy_fork_network_on_archive.yml similarity index 87% rename from config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml rename to config/feature_flags/beta/destroy_fork_network_on_archive.yml index 684900483a6e1d..d6efb391241e0d 100644 --- a/config/feature_flags/gitlab_com_derisk/destroy_fork_network_on_archive.yml +++ b/config/feature_flags/beta/destroy_fork_network_on_archive.yml @@ -3,7 +3,7 @@ name: destroy_fork_network_on_archive feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/518708 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189438 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/538925 -milestone: '18.00' -type: gitlab_com_derisk +milestone: '18.0' +type: beta group: group::source code default_enabled: false -- GitLab From 5e6cffaa822c6a96478cdb8599c109ceb304c21a Mon Sep 17 00:00:00 2001 From: Emma Park Date: Thu, 8 May 2025 13:51:02 +0900 Subject: [PATCH 5/6] Improve RSpec test descriptions for clarity --- .../merge_requests/close_service_spec.rb | 34 ++++++++++++------- spec/services/projects/update_service_spec.rb | 32 +++++++---------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index 559b8faf94c4a4..55c9b3ff1d0b90 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -144,7 +144,7 @@ def execute end end - context 'with skip_authorization param' do + describe 'handling authorization' do let(:service_with_skip) do described_class.new( project: project, @@ -155,30 +155,38 @@ def execute let(:service_without_skip) { described_class.new(project: project, current_user: guest) } - it 'closes MR if skip_authorization param is present' do - result = service_with_skip.execute(merge_request) + context 'when authorization is skipped' do + it 'closes MR' do + result = service_with_skip.execute(merge_request) - expect(result).to be_closed + expect(result).to be_closed + end end - it 'does not close MR if skip_authorization param is not present and user has no permission' do - result = service_without_skip.execute(merge_request) + context 'when authorization is not skipped' do + it 'does not close MR' do + result = service_without_skip.execute(merge_request) - expect(result).to be_open + expect(result).to be_open + end end + end - context 'with feature flag :destroy_fork_network_on_archive disabled' do - before do - stub_feature_flags(destroy_fork_network_on_archive: false) - end + context 'with feature flag :destroy_fork_network_on_archive disabled' do + before do + stub_feature_flags(destroy_fork_network_on_archive: false) + end - it 'closes MR if user has permission' do + context 'when user has permission' do + it 'closes MR' do result = service.execute(merge_request) expect(result).to be_closed end + end - it 'does not close MR if user lacks permission' do + context 'when user does not have permission' do + it 'does not close MR' do guest_service = described_class.new(project: project, current_user: guest) result = guest_service.execute(merge_request) diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index c4733bfce1eed9..804ddacf5733a5 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -533,33 +533,27 @@ ) end - context 'when feature flag is ON and project is being archived' do - it 'calls UnlinkForkService if project is being archived now' do - project.update!(archived: false) + context 'when the destroy_fork_network_on_archive feature flag is enabled' do + context 'when project is being archived' do + it 'calls UnlinkForkService' do + project.update!(archived: false) - expect(Projects::UnlinkForkService).to receive(:new).with(project, user).and_return(unlink_fork_service) + expect(Projects::UnlinkForkService).to receive(:new).with(project, user).and_return(unlink_fork_service) - update_project(project, user, archived: true) - end - - it 'does not call UnlinkForkService if project was already archived' do - project.update!(archived: true) - - expect(Projects::UnlinkForkService).not_to receive(:new) - - update_project(project, user, archived: true) + update_project(project, user, archived: true) + end end - end - context 'when project is not being archived' do - it 'does not call UnlinkForkService' do - expect(Projects::UnlinkForkService).not_to receive(:new) + context 'when project is not being archived' do + it 'does not call UnlinkForkService' do + expect(Projects::UnlinkForkService).not_to receive(:new) - update_project(project, user, archived: false) + update_project(project, user, archived: false) + end end end - context 'when feature flag is OFF' do + context 'when the destroy_fork_network_on_archive feature flag is disabled' do before do stub_feature_flags(destroy_fork_network_on_archive: false) end -- GitLab From 4a0550beaccc38c51e474c071976106668c944e4 Mon Sep 17 00:00:00 2001 From: Emma Park Date: Thu, 8 May 2025 14:11:16 +0900 Subject: [PATCH 6/6] Improve documentation clarity and consistency --- doc/user/project/repository/forking_workflow.md | 9 +++++++-- doc/user/project/working_with_projects.md | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md index b4ceb03ae3c6cc..fb682f48678061 100644 --- a/doc/user/project/repository/forking_workflow.md +++ b/doc/user/project/repository/forking_workflow.md @@ -28,9 +28,13 @@ Make your changes in your fork, then submit them through a merge request to the To create a [confidential merge request](../merge_requests/confidential.md), use a personal fork of a public repository. -**Note:** If the upstream project is archived, the fork relationship is automatically removed. +{{< alert type="note" >}} + +If the upstream project is archived, the fork relationship is automatically removed. For more information, see [Archive a project](../working_with_projects.md#archive-a-project) +{{< /alert >}} + ## Create a fork {{< history >}} @@ -169,7 +173,8 @@ Prerequisites: {{< alert type="warning" >}} -If you remove a fork relationship, you can't send new merge requests to the source. Any existing open merge requests from the fork to the source are also closed. +If you remove a fork relationship, you can't send new merge requests to the source. +Any existing open merge requests from the fork to the source are also closed. If anyone has forked your repository, their fork also loses the relationship. To restore the fork relationship, [use the API](../../../api/project_forks.md#create-a-fork-relationship-between-projects). diff --git a/doc/user/project/working_with_projects.md b/doc/user/project/working_with_projects.md index 71b53c57244743..b222e559dfb784 100644 --- a/doc/user/project/working_with_projects.md +++ b/doc/user/project/working_with_projects.md @@ -413,6 +413,13 @@ To restore a project marked for deletion: {{< /history >}} +{{< alert type="note" >}} + +When a project is archived, its fork relationship is removed and any open merge requests from forks +targeting this project are automatically closed. + +{{< /alert >}} + When you archive a project, some features become read-only. These features are still accessible, but not writable. @@ -424,8 +431,6 @@ These features are still accessible, but not writable. - Pull mirroring - All other project features -**Note:** When a project is archived, its fork relationship is removed and any open merge requests from forks targeting this project are automatically closed. - Active pipeline schedules of archived projects don't become read-only. If the project has deployed Pages, they are removed along with any custom domains, -- GitLab