From 4949c42097784378c76cf8a7d763decf231f936d Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Tue, 11 Nov 2025 18:17:59 +0100 Subject: [PATCH 01/11] Handle forking workflow When user does not have permissions to push code, a commit is made to their fork of the project. This is followed by a redirect to a new merge request that compares the newly created branch in a forked project to a source branch in a source project. --- .../javascripts/blob_edit/blob_edit_header.js | 4 + .../repository/pages/blob_edit_header.vue | 69 ++++++++---- app/controllers/projects/blob_controller.rb | 7 ++ app/helpers/blob_helper.rb | 10 +- .../projects/files/user_edits_files_spec.rb | 1 - .../repository/pages/blob_edit_header_spec.js | 103 +++++++++++++----- 6 files changed, 143 insertions(+), 51 deletions(-) diff --git a/app/assets/javascripts/blob_edit/blob_edit_header.js b/app/assets/javascripts/blob_edit/blob_edit_header.js index dc76c639ecbe22..f61626d5cb53c9 100644 --- a/app/assets/javascripts/blob_edit/blob_edit_header.js +++ b/app/assets/javascripts/blob_edit/blob_edit_header.js @@ -24,6 +24,8 @@ export default function initBlobEditHeader(editor) { projectId, projectPath, newMergeRequestPath, + forkProjectId, + forkProjectPath, } = el.dataset; return new Vue({ @@ -40,6 +42,8 @@ export default function initBlobEditHeader(editor) { projectId, projectPath, newMergeRequestPath, + forkProjectId, + forkProjectPath, emptyRepo: parseBoolean(emptyRepo), canPushCode: parseBoolean(canPushCode), canPushToBranch: parseBoolean(canPushToBranch), diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 2ca6b412cd42a6..93c25f3c840781 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -50,6 +50,8 @@ export default { 'projectId', 'projectPath', 'newMergeRequestPath', + 'forkProjectId', + 'forkProjectPath', ], data() { return { @@ -105,6 +107,12 @@ export default { }, }, methods: { + getTargetProjectId() { + return this.canPushToBranch ? this.projectId : this.forkProjectId || this.projectId; + }, + getTargetProjectPath() { + return this.canPushToBranch ? this.projectPath : this.forkProjectPath || this.projectPath; + }, handleCancelButtonClick() { window.onbeforeunload = null; }, @@ -122,7 +130,7 @@ export default { }, getUpdatePath(branch, filePath) { const url = new URL(window.location.href); - url.pathname = joinPaths(this.projectPath, '-/blob', branch, filePath); + url.pathname = joinPaths(this.getTargetProjectPath(), '-/blob', branch, filePath); return url.toString(); }, getResultingBranch(responseData, formData) { @@ -140,22 +148,27 @@ export default { return this.editBlobWithUpdateFileApi(originalFormData); }, editBlobWithUpdateFileApi(originalFormData) { + const targetProjectId = this.getTargetProjectId(); + const url = buildApiUrl(this.$options.UPDATE_FILE_PATH) - .replace(':id', this.projectId) + .replace(':id', targetProjectId) .replace(':file_path', encodeURIComponent(this.originalFilePath)); const data = { ...prepareDataForApiEdit(originalFormData), content: originalFormData.file, file_path: originalFormData.file_path, - id: this.projectId, + id: targetProjectId, last_commit_id: originalFormData.last_commit_sha, }; return axios.put(url, data); }, editBlobWithCommitsApi(originalFormData) { - const url = buildApiUrl(this.$options.COMMIT_FILE_PATH).replace(':id', this.projectId); + const url = buildApiUrl(this.$options.COMMIT_FILE_PATH).replace( + ':id', + this.getTargetProjectId(), + ); const action = { action: 'move', @@ -253,9 +266,10 @@ export default { handleEditBlobSuccess(responseData, formData) { const resultingBranch = this.getResultingBranch(responseData, formData); const isNewBranch = this.originalBranch !== resultingBranch; + const isWorkingOnFork = !this.canPushToBranch; + const url = new URL(window.location.href); - if (this.fromMergeRequestIid && resultingBranch === this.originalBranch) { - const url = new URL(window.location.href); + if (this.fromMergeRequestIid && !isNewBranch) { url.pathname = joinPaths(this.projectPath, '/-/merge_requests/', this.fromMergeRequestIid); const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); visitUrl(cleanUrl); @@ -268,23 +282,40 @@ export default { this.newMergeRequestPath, ); visitUrl(mrUrl); - } else { - const successPath = this.getUpdatePath( - resultingBranch, - responseData.file_path || formData.file_path, - ); - const createMergeRequestNotChosen = !formData.create_merge_request; + return; + } - const message = this.successMessageForAlert(isNewBranch, createMergeRequestNotChosen); + if (isWorkingOnFork) { + // When working on a fork, redirect to merge request creation in the fork + // with parameters to compare fork branch to source project branch + url.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); - saveAlertToLocalStorage({ - message, - messageLinks: { changesLink: successPath }, - variant: VARIANT_INFO, - }); + const mrParams = { + 'merge_request[source_project_id]': this.getTargetProjectId(), + 'merge_request[source_branch]': resultingBranch, + 'merge_request[target_project_id]': this.projectId, + 'merge_request[target_branch]': this.originalBranch, + }; - visitUrl(successPath); + const mrUrl = mergeUrlParams(mrParams, url.toString()); + visitUrl(mrUrl); + return; } + const successPath = this.getUpdatePath( + resultingBranch, + responseData.file_path || formData.file_path, + ); + const createMergeRequestNotChosen = !formData.create_merge_request; + + const message = this.successMessageForAlert(isNewBranch, createMergeRequestNotChosen); + + saveAlertToLocalStorage({ + message, + messageLinks: { changesLink: successPath }, + variant: VARIANT_INFO, + }); + + visitUrl(successPath); }, handleControllerSuccess(responseData) { if (responseData.filePath) { diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 92e7443c8cc334..bf8ec13789ce4e 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -259,6 +259,13 @@ def editor_variables params[:content] = params[:file] if params[:file].present? + # Determine fork project ID if user is working on a fork + @fork_project_id = if can_collaborate_with_project?(project, ref: @ref) + nil + else + current_user.fork_of(project)&.id + end + @commit_params = { file_path: @file_path, commit_message: params[:commit_message], diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index b7e0f568f5efc4..34c05a9148da71 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -340,6 +340,12 @@ def edit_blob_app_data(project, id, blob, ref, action) project_tree_path(project, id) end + fork_of_project = current_user&.fork_of(project) + + if current_user.already_forked?(project) && !current_user.has_groups_allowing_project_creation? + user_fork_url = namespace_project_path(current_user, fork_of_project) + end + { action: action.to_s, update_path: update_path, @@ -354,7 +360,9 @@ def edit_blob_app_data(project, id, blob, ref, action) last_commit_sha: @last_commit_sha, project_id: project.id, project_path: project.full_path, - new_merge_request_path: project_new_merge_request_path(project) + new_merge_request_path: project_new_merge_request_path(project), + fork_project_id: fork_of_project&.id, + fork_project_path: user_fork_url } end diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 987083fef361b9..ed129d3420e4e3 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -184,7 +184,6 @@ context 'when an user does not have write access', :js do before do - stub_feature_flags(blob_edit_refactor: false) project2.add_reporter(user) visit(project2_tree_path_root_ref) wait_for_requests diff --git a/spec/frontend/repository/pages/blob_edit_header_spec.js b/spec/frontend/repository/pages/blob_edit_header_spec.js index 4e034c8d62e2db..51e1d79612583e 100644 --- a/spec/frontend/repository/pages/blob_edit_header_spec.js +++ b/spec/frontend/repository/pages/blob_edit_header_spec.js @@ -56,6 +56,8 @@ describe('BlobEditHeader', () => { projectId: 123, projectPath: 'gitlab-org/gitlab', newMergeRequestPath: 'merge_request/new/123', + forkProjectId: null, + forkProjectPath: null, ...provided, glFeatures: { blobEditRefactor: true, ...glFeatures }, }, @@ -230,36 +232,6 @@ describe('BlobEditHeader', () => { removeParamsSpy.mockRestore(); }); - it('on submit to new branch to a fork repo, saves success message with "original project" text', async () => { - // Create wrapper with canPushToBranch: false to simulate fork scenario - createWrapper({ - glFeatures: { blobEditRefactor: true }, - provided: { canPushToBranch: false }, - }); - - // Need to click the commit button first to open modal and set up file content - clickCommitChangesButton(); - - mock.onPut().replyOnce(HTTP_STATUS_OK, { - branch: 'feature', - file_path: 'test.js', - }); - await submitForm(); - - expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ - message: - 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully. You can now submit a merge request to get this change into the original project.', - messageLinks: { - changesLink: 'http://test.host/gitlab-org/gitlab/-/blob/feature/test.js', - }, - variant: 'info', - }); - - expect(visitUrlSpy).toHaveBeenCalledWith( - 'http://test.host/gitlab-org/gitlab/-/blob/feature/test.js', - ); - }); - describe('error handling', () => { const errorMessage = 'Custom error message'; @@ -326,6 +298,11 @@ describe('BlobEditHeader', () => { clickCommitChangesButton(); }); + afterEach(() => { + // Restore to initial value + mockEditor.filepathFormMediator.$filenameInput.val.mockReturnValue('test.js'); + }); + it('uses commits API when file path changes', async () => { mock.onPost().replyOnce(HTTP_STATUS_OK, {}); @@ -394,6 +371,72 @@ describe('BlobEditHeader', () => { expect(visitUrlSpy).not.toHaveBeenCalled(); }); }); + + describe('when working on a fork', () => { + beforeEach(async () => { + createWrapper({ + glFeatures: { blobEditRefactor: true }, + provided: { + canPushToBranch: false, + forkProjectId: 456, + forkProjectPath: 'user/gitlab-fork', + }, + }); + + clickCommitChangesButton(); + mock.onPut().replyOnce(HTTP_STATUS_OK, { + branch: 'feature', + file_path: 'test.js', + }); + + await submitForm(); + }); + + it('on submit, redirects to merge request creation', () => { + expect(mock.history.put).toHaveLength(1); + expect(mock.history.put[0].url).toBe('/api/v4/projects/456/repository/files/test.js'); + const putData = JSON.parse(mock.history.put[0].data); + expect(putData.content).toBe(content); + + expect(visitUrlSpy).toHaveBeenCalledWith( + 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=feature&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', + ); + expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); + }); + }); + + describe('when renaming a file in fork', () => { + beforeEach(async () => { + createWrapper({ + glFeatures: { blobEditRefactor: true }, + provided: { + canPushToBranch: false, + forkProjectId: 456, + forkProjectPath: 'user/gitlab-fork', + }, + }); + mockEditor.filepathFormMediator.$filenameInput.val.mockReturnValue('renamed_test.js'); + mockEditor.getOriginalFilePath.mockReturnValue('test.js'); + clickCommitChangesButton(); + mock.onPost().replyOnce(HTTP_STATUS_OK, {}); + + await submitForm(); + }); + + afterEach(() => { + // Restore to initial value + mockEditor.filepathFormMediator.$filenameInput.val.mockReturnValue('test.js'); + }); + + it('redirects to merge request creation page after renaming file', () => { + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].url).toBe('/api/v4/projects/456/repository/commits'); + expect(visitUrlSpy).toHaveBeenCalledWith( + 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=feature&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', + ); + expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); + }); + }); }); describe('when blobEditRefactor is disabled', () => { -- GitLab From c20e7d0f495e826163765fa2cae78e34c3a50232 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 13 Nov 2025 21:07:58 +0100 Subject: [PATCH 02/11] Allow feature tests to run against refactored code --- spec/features/projects/files/editing_a_file_spec.rb | 2 +- spec/features/projects/files/user_edits_files_spec.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb index 798155aa1d248a..416ae48b4d3b6b 100644 --- a/spec/features/projects/files/editing_a_file_spec.rb +++ b/spec/features/projects/files/editing_a_file_spec.rb @@ -91,7 +91,7 @@ end expect(page).to have_content( - 'An error occurred editing the blob' + 'You are attempting to update a file that has changed since you started editing it.' ) end end diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index ed129d3420e4e3..931d193d5f9ee7 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -283,7 +283,6 @@ def expect_fork_status let!(:forked_project) { fork_project(project2, user, namespace: user.namespace, repository: true) } before do - stub_feature_flags(blob_edit_refactor: false) visit(project2_tree_path_root_ref) wait_for_requests end -- GitLab From 325be79478415dfb96121c37d360d9a5a73a8e4d Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 4 Dec 2025 16:06:58 +0100 Subject: [PATCH 03/11] Handle maintainer collaboration flow --- .../repository/pages/blob_edit_header.vue | 49 +++++-- app/helpers/blob_helper.rb | 56 +++++--- .../maintainer_edits_fork_spec.rb | 1 - .../repository/pages/blob_edit_header_spec.js | 42 ++++++ spec/helpers/blob_helper_spec.rb | 133 +++++++++++++++++- 5 files changed, 242 insertions(+), 39 deletions(-) diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 93c25f3c840781..32a6351a3ec656 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -128,10 +128,11 @@ export default { this.originalFilePath = this.editor.getOriginalFilePath(); this.$refs[this.updateModalId].show(); }, - getUpdatePath(branch, filePath) { - const url = new URL(window.location.href); - url.pathname = joinPaths(this.getTargetProjectPath(), '-/blob', branch, filePath); - return url.toString(); + getUpdatePath(baseUrl, options) { + const { targetPath, branch, filePath } = options; + const urlObj = new URL(baseUrl instanceof URL ? baseUrl.toString() : baseUrl); + urlObj.pathname = joinPaths(targetPath, '-/blob', branch, filePath); + return urlObj.toString(); }, getResultingBranch(responseData, formData) { return responseData.branch || formData.branch_name || formData.original_branch; @@ -269,13 +270,27 @@ export default { const isWorkingOnFork = !this.canPushToBranch; const url = new URL(window.location.href); - if (this.fromMergeRequestIid && !isNewBranch) { - url.pathname = joinPaths(this.projectPath, '/-/merge_requests/', this.fromMergeRequestIid); - const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); + // When editing via MR and committing to the same branch, redirect back to MR + // But skip this if we're collaborating on a fork (maintainer editing contributor's fork) + if ( + this.fromMergeRequestIid && + resultingBranch === this.originalBranch && + !this.branchAllowsCollaboration + ) { + // Build the MR URL first, then remove the from_merge_request_iid parameter + const mrUrl = new URL(url.toString()); + mrUrl.pathname = joinPaths( + this.projectPath, + '/-/merge_requests/', + this.fromMergeRequestIid, + ); + const cleanUrl = removeParams(['from_merge_request_iid'], mrUrl.toString()); visitUrl(cleanUrl); return; } + const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); + if (formData.create_merge_request && isNewBranch) { const mrUrl = mergeUrlParams( { [MR_SOURCE_BRANCH]: resultingBranch }, @@ -286,9 +301,10 @@ export default { } if (isWorkingOnFork) { - // When working on a fork, redirect to merge request creation in the fork + // When working on a fork (user's own fork), redirect to merge request creation in the fork // with parameters to compare fork branch to source project branch - url.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); + const mrUrl = new URL(cleanUrl); + mrUrl.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); const mrParams = { 'merge_request[source_project_id]': this.getTargetProjectId(), @@ -297,14 +313,17 @@ export default { 'merge_request[target_branch]': this.originalBranch, }; - const mrUrl = mergeUrlParams(mrParams, url.toString()); - visitUrl(mrUrl); + const finalMrUrl = mergeUrlParams(mrParams, mrUrl.toString()); + visitUrl(finalMrUrl); return; } - const successPath = this.getUpdatePath( - resultingBranch, - responseData.file_path || formData.file_path, - ); + + // Default: redirect to blob page with success alert + const successPath = this.getUpdatePath(cleanUrl, { + targetPath: this.getTargetProjectPath(), + branch: resultingBranch, + filePath: responseData.file_path || formData.file_path, + }); const createMergeRequestNotChosen = !formData.create_merge_request; const message = this.successMessageForAlert(isNewBranch, createMergeRequestNotChosen); diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 34c05a9148da71..11f4b3efa73107 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -328,28 +328,11 @@ def vue_blob_header_app_data(project, blob, ref) def edit_blob_app_data(project, id, blob, ref, action) is_update = action == 'update' is_create = action == 'create' - update_path = if is_update - project_update_blob_path(project, id) - elsif is_create - project_create_blob_path(project, id) - end - - cancel_path = if is_update - project_blob_path(project, id) - elsif is_create - project_tree_path(project, id) - end - - fork_of_project = current_user&.fork_of(project) - - if current_user.already_forked?(project) && !current_user.has_groups_allowing_project_creation? - user_fork_url = namespace_project_path(current_user, fork_of_project) - end { action: action.to_s, - update_path: update_path, - cancel_path: cancel_path, + update_path: edit_blob_update_path(project, id, is_update, is_create), + cancel_path: edit_blob_cancel_path(project, id, is_update, is_create), original_branch: ref, target_branch: selected_branch, can_push_code: can?(current_user, :push_code, project).to_s, @@ -361,13 +344,44 @@ def edit_blob_app_data(project, id, blob, ref, action) project_id: project.id, project_path: project.full_path, new_merge_request_path: project_new_merge_request_path(project), - fork_project_id: fork_of_project&.id, - fork_project_path: user_fork_url + fork_project_id: edit_blob_fork_project_id(project, ref), + fork_project_path: edit_blob_fork_project_path(project) } end private + def edit_blob_update_path(project, id, is_update, is_create) + if is_update + project_update_blob_path(project, id) + elsif is_create + project_create_blob_path(project, id) + end + end + + def edit_blob_cancel_path(project, id, is_update, is_create) + if is_update + project_blob_path(project, id) + elsif is_create + project_tree_path(project, id) + end + end + + def edit_blob_fork_project_id(project, ref) + return unless can_collaborate_with_project?(project, ref: ref) + + current_user&.fork_of(project)&.id + end + + def edit_blob_fork_project_path(project) + return unless current_user + + fork_of_project = current_user.fork_of(project) + return unless current_user.already_forked?(project) && !current_user.has_groups_allowing_project_creation? + + namespace_project_path(current_user, fork_of_project) + end + def archive_download_links(project, ref, archive_prefix) Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt| { diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb index 92969a89c067a2..f50af8dace9da4 100644 --- a/spec/features/merge_request/maintainer_edits_fork_spec.rb +++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb @@ -24,7 +24,6 @@ end before do - stub_feature_flags(blob_edit_refactor: false) target_project.add_maintainer(user) sign_in(user) diff --git a/spec/frontend/repository/pages/blob_edit_header_spec.js b/spec/frontend/repository/pages/blob_edit_header_spec.js index 51e1d79612583e..af4096b1c11f54 100644 --- a/spec/frontend/repository/pages/blob_edit_header_spec.js +++ b/spec/frontend/repository/pages/blob_edit_header_spec.js @@ -232,6 +232,48 @@ describe('BlobEditHeader', () => { removeParamsSpy.mockRestore(); }); + it('when branchAllowsCollaboration is true, skips MR redirect and shows success message', async () => { + // Mock URL with from_merge_request_iid parameter + const originalLocation = window.location; + delete window.location; + window.location = new URL( + 'http://test.host/gitlab-org/gitlab/-/edit/main/test.js?from_merge_request_iid=19', + ); + + createWrapper({ + glFeatures: { blobEditRefactor: true }, + provided: { + branchAllowsCollaboration: true, + }, + }); + + clickCommitChangesButton(); + mock.onPut().replyOnce(HTTP_STATUS_OK, { + branch: 'main', // Same as originalBranch + file_path: 'test.js', + }); + + await submitForm(); + await axios.waitForAll(); + + // Should NOT redirect to MR, but instead show success message and redirect to blob + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: + 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully.', + messageLinks: { + changesLink: 'http://test.host/gitlab-org/gitlab/-/blob/main/test.js', + }, + variant: 'info', + }); + + expect(visitUrlSpy).toHaveBeenCalledWith( + 'http://test.host/gitlab-org/gitlab/-/blob/main/test.js', + ); + + // Restore original location + window.location = originalLocation; + }); + describe('error handling', () => { const errorMessage = 'Custom error message'; diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index cc3dc33d5c1afc..4a4d35b42ca87e 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -480,6 +480,7 @@ before do allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:selected_branch).and_return(ref) + allow(user).to receive(:namespace).and_return(build_stubbed(:namespace, owner: user)) end context 'when editing a blob' do @@ -487,6 +488,7 @@ project_presenter = instance_double(ProjectPresenter) allow(helper).to receive(:can?).with(user, :push_code, project).and_return(true) + allow(helper).to receive(:can?).with(user, :create_merge_request_in, project).and_return(true) allow(project).to receive(:present).and_return(project_presenter) allow(project_presenter).to receive(:can_current_user_push_to_branch?).with(ref).and_return(true) allow(project).to receive(:empty_repo?).and_return(false) @@ -535,8 +537,8 @@ context 'when user cannot push code' do it 'returns false for push permissions' do + allow(helper).to receive(:can?).with(user, :create_merge_request_in, project).and_return(false) allow(helper).to receive(:can?).with(user, :push_code, project).and_return(false) - expect(helper.edit_blob_app_data(project, id, blob, ref, "update")).to include( can_push_code: 'false' ) @@ -546,7 +548,8 @@ context 'when user cannot push to branch' do it 'returns false for branch push permissions' do project_presenter = instance_double(ProjectPresenter) - + allow(helper).to receive(:can?).with(user, :create_merge_request_in, project).and_return(false) + allow(helper).to receive(:can?).with(user, :push_code, project).and_return(false) allow(project).to receive(:present).and_return(project_presenter) allow(project_presenter).to receive(:can_current_user_push_to_branch?).with(ref).and_return(false) @@ -575,6 +578,132 @@ ) end end + + describe '#edit_blob_update_path' do + it 'returns update path for update action' do + path = helper.send(:edit_blob_update_path, project, id, true, false) + + expect(path).to eq(project_update_blob_path(project, id)) + end + + it 'returns create path for create action' do + path = helper.send(:edit_blob_update_path, project, id, false, true) + + expect(path).to eq(project_create_blob_path(project, id)) + end + end + + describe '#edit_blob_cancel_path' do + it 'returns blob path for update action' do + path = helper.send(:edit_blob_cancel_path, project, id, true, false) + + expect(path).to eq(project_blob_path(project, id)) + end + + it 'returns tree path for create action' do + path = helper.send(:edit_blob_cancel_path, project, id, false, true) + + expect(path).to eq(project_tree_path(project, id)) + end + end + + describe '#edit_blob_fork_project_id' do + context 'when user can collaborate with project' do + before do + allow(helper).to receive(:can_collaborate_with_project?).with(project, ref: ref).and_return(true) + end + + it 'returns fork project id when user has forked the project' do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + + fork_id = helper.send(:edit_blob_fork_project_id, project, ref) + + expect(fork_id).to eq(999) + end + + it 'returns nil when user has not forked the project' do + allow(user).to receive(:fork_of).with(project).and_return(nil) + + fork_id = helper.send(:edit_blob_fork_project_id, project, ref) + + expect(fork_id).to be_nil + end + end + + context 'when user cannot collaborate with project' do + before do + allow(helper).to receive(:can_collaborate_with_project?).with(project, ref: ref).and_return(false) + end + + it 'returns nil' do + fork_id = helper.send(:edit_blob_fork_project_id, project, ref) + + expect(fork_id).to be_nil + end + end + end + + describe '#edit_blob_fork_project_path' do + context 'when user is logged in' do + context 'when user has forked the project and cannot create groups' do + before do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + allow(user).to receive(:already_forked?).with(project).and_return(true) + allow(user).to receive(:has_groups_allowing_project_creation?).and_return(false) + end + + it 'returns the fork project path' do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + + path = helper.send(:edit_blob_fork_project_path, project) + + expect(path).to eq(namespace_project_path(user, fork_project)) + end + end + + context 'when user has not forked the project' do + before do + allow(user).to receive(:already_forked?).with(project).and_return(false) + end + + it 'returns nil' do + path = helper.send(:edit_blob_fork_project_path, project) + + expect(path).to be_nil + end + end + + context 'when user can create groups' do + before do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + allow(user).to receive(:already_forked?).with(project).and_return(true) + allow(user).to receive(:has_groups_allowing_project_creation?).and_return(true) + end + + it 'returns nil' do + path = helper.send(:edit_blob_fork_project_path, project) + + expect(path).to be_nil + end + end + end + + context 'when user is not logged in' do + before do + allow(helper).to receive(:current_user).and_return(nil) + end + + it 'returns nil' do + path = helper.send(:edit_blob_fork_project_path, project) + + expect(path).to be_nil + end + end + end end describe "#copy_blob_source_button" do -- GitLab From 96b59c7e6cf10e44e6aeeb8ac5b91ef1bcbc3052 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Mon, 8 Dec 2025 17:25:44 +0100 Subject: [PATCH 04/11] Use can_push_to_branch for authorization --- lib/api/files.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/api/files.rb b/lib/api/files.rb index 8d0db574770273..db50609bb4ca2e 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -66,6 +66,18 @@ def authorize_ai_access! forbidden!('Insufficient permissions for Duo Agent Platform') end + def user_access + @user_access ||= Gitlab::UserAccess.new(current_user, container: user_project) + end + + def authorize_push_to_branch!(branch) + authenticate! + + return if user_access.can_push_to_branch?(branch) + + forbidden!("You are not allowed to push into this branch") + end + def commit_response(attrs) { file_path: attrs[:file_path], @@ -333,7 +345,7 @@ def filter_file_params!(params) end put ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS, urgency: :low do require_gitlab_workhorse! - authorize! :push_code, user_project + authorize_push_to_branch!(params[:branch]) file_params = validate_file_params!(file_params_from_body_upload) file_params[:file_path] = params[:file_path] -- GitLab From 74676eecd9d085b6d3f65550f2f27230bb814504 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Mon, 8 Dec 2025 19:46:40 +0100 Subject: [PATCH 05/11] Refactor edit success to limit complexity --- .../repository/pages/blob_edit_header.vue | 99 ++++++++++--------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 32a6351a3ec656..79240d3d89b72c 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -267,58 +267,67 @@ export default { handleEditBlobSuccess(responseData, formData) { const resultingBranch = this.getResultingBranch(responseData, formData); const isNewBranch = this.originalBranch !== resultingBranch; - const isWorkingOnFork = !this.canPushToBranch; const url = new URL(window.location.href); + const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); - // When editing via MR and committing to the same branch, redirect back to MR - // But skip this if we're collaborating on a fork (maintainer editing contributor's fork) - if ( + if (this.shouldRedirectToExistingMR(resultingBranch)) { + this.redirectToExistingMergeRequest(url); + } else if (this.shouldCreateNewMR(formData, isNewBranch)) { + this.redirectToCreateMergeRequest(resultingBranch); + } else if (this.isWorkingOnFork()) { + this.redirectToForkMergeRequest(cleanUrl, resultingBranch); + } else { + this.redirectToBlobWithAlert({ + cleanUrl, + resultingBranch, + responseData, + formData, + isNewBranch, + }); + } + }, + shouldRedirectToExistingMR(resultingBranch) { + return ( this.fromMergeRequestIid && resultingBranch === this.originalBranch && !this.branchAllowsCollaboration - ) { - // Build the MR URL first, then remove the from_merge_request_iid parameter - const mrUrl = new URL(url.toString()); - mrUrl.pathname = joinPaths( - this.projectPath, - '/-/merge_requests/', - this.fromMergeRequestIid, - ); - const cleanUrl = removeParams(['from_merge_request_iid'], mrUrl.toString()); - visitUrl(cleanUrl); - return; - } - - const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); - - if (formData.create_merge_request && isNewBranch) { - const mrUrl = mergeUrlParams( - { [MR_SOURCE_BRANCH]: resultingBranch }, - this.newMergeRequestPath, - ); - visitUrl(mrUrl); - return; - } - - if (isWorkingOnFork) { - // When working on a fork (user's own fork), redirect to merge request creation in the fork - // with parameters to compare fork branch to source project branch - const mrUrl = new URL(cleanUrl); - mrUrl.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); - - const mrParams = { - 'merge_request[source_project_id]': this.getTargetProjectId(), - 'merge_request[source_branch]': resultingBranch, - 'merge_request[target_project_id]': this.projectId, - 'merge_request[target_branch]': this.originalBranch, - }; + ); + }, + shouldCreateNewMR(formData, isNewBranch) { + return formData.create_merge_request && isNewBranch; + }, + isWorkingOnFork() { + return !this.canPushToBranch; + }, + redirectToExistingMergeRequest(url) { + const mrUrl = new URL(url.toString()); + mrUrl.pathname = joinPaths(this.projectPath, '/-/merge_requests/', this.fromMergeRequestIid); + const cleanUrl = removeParams(['from_merge_request_iid'], mrUrl.toString()); + visitUrl(cleanUrl); + }, + redirectToCreateMergeRequest(resultingBranch) { + const mrUrl = mergeUrlParams( + { [MR_SOURCE_BRANCH]: resultingBranch }, + this.newMergeRequestPath, + ); + visitUrl(mrUrl); + }, + redirectToForkMergeRequest(cleanUrl, resultingBranch) { + const mrUrl = new URL(cleanUrl); + mrUrl.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); - const finalMrUrl = mergeUrlParams(mrParams, mrUrl.toString()); - visitUrl(finalMrUrl); - return; - } + const mrParams = { + 'merge_request[source_project_id]': this.getTargetProjectId(), + 'merge_request[source_branch]': resultingBranch, + 'merge_request[target_project_id]': this.projectId, + 'merge_request[target_branch]': this.originalBranch, + }; - // Default: redirect to blob page with success alert + const finalMrUrl = mergeUrlParams(mrParams, mrUrl.toString()); + visitUrl(finalMrUrl); + }, + redirectToBlobWithAlert(options) { + const { cleanUrl, resultingBranch, responseData, formData, isNewBranch } = options; const successPath = this.getUpdatePath(cleanUrl, { targetPath: this.getTargetProjectPath(), branch: resultingBranch, -- GitLab From 52499ccaa6be96803b0954b2626311442b9126e5 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Tue, 9 Dec 2025 12:15:23 +0100 Subject: [PATCH 06/11] Move methods to computed props --- .../repository/pages/blob_edit_header.vue | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 79240d3d89b72c..37fce2c2cf4e54 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -64,6 +64,12 @@ export default { }; }, computed: { + getTargetProjectId() { + return this.canPushToBranch ? this.projectId : this.forkProjectId || this.projectId; + }, + getTargetProjectPath() { + return this.canPushToBranch ? this.projectPath : this.forkProjectPath || this.projectPath; + }, updateModalId() { return uniqueId('update-modal'); }, @@ -107,12 +113,6 @@ export default { }, }, methods: { - getTargetProjectId() { - return this.canPushToBranch ? this.projectId : this.forkProjectId || this.projectId; - }, - getTargetProjectPath() { - return this.canPushToBranch ? this.projectPath : this.forkProjectPath || this.projectPath; - }, handleCancelButtonClick() { window.onbeforeunload = null; }, @@ -149,7 +149,7 @@ export default { return this.editBlobWithUpdateFileApi(originalFormData); }, editBlobWithUpdateFileApi(originalFormData) { - const targetProjectId = this.getTargetProjectId(); + const targetProjectId = this.getTargetProjectId; const url = buildApiUrl(this.$options.UPDATE_FILE_PATH) .replace(':id', targetProjectId) @@ -168,7 +168,7 @@ export default { editBlobWithCommitsApi(originalFormData) { const url = buildApiUrl(this.$options.COMMIT_FILE_PATH).replace( ':id', - this.getTargetProjectId(), + this.getTargetProjectId, ); const action = { @@ -314,10 +314,10 @@ export default { }, redirectToForkMergeRequest(cleanUrl, resultingBranch) { const mrUrl = new URL(cleanUrl); - mrUrl.pathname = joinPaths(this.getTargetProjectPath(), '/-/merge_requests/new'); + mrUrl.pathname = joinPaths(this.getTargetProjectPath, '/-/merge_requests/new'); const mrParams = { - 'merge_request[source_project_id]': this.getTargetProjectId(), + 'merge_request[source_project_id]': this.getTargetProjectId, 'merge_request[source_branch]': resultingBranch, 'merge_request[target_project_id]': this.projectId, 'merge_request[target_branch]': this.originalBranch, @@ -329,7 +329,7 @@ export default { redirectToBlobWithAlert(options) { const { cleanUrl, resultingBranch, responseData, formData, isNewBranch } = options; const successPath = this.getUpdatePath(cleanUrl, { - targetPath: this.getTargetProjectPath(), + targetPath: this.getTargetProjectPath, branch: resultingBranch, filePath: responseData.file_path || formData.file_path, }); -- GitLab From 9d7930c4360a191acddd03c6a588014112284690 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Wed, 10 Dec 2025 19:28:10 +0100 Subject: [PATCH 07/11] Provide unique branch named patch-N for fork project --- .../javascripts/blob_edit/blob_edit_header.js | 2 + .../repository/pages/blob_edit_header.vue | 11 ++- app/helpers/blob_helper.rb | 9 ++- .../projects/files/user_edits_files_spec.rb | 1 + .../repository/pages/blob_edit_header_spec.js | 10 ++- spec/helpers/blob_helper_spec.rb | 68 +++++++++++++++++++ 6 files changed, 94 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/blob_edit/blob_edit_header.js b/app/assets/javascripts/blob_edit/blob_edit_header.js index f61626d5cb53c9..f79b1e180d56fd 100644 --- a/app/assets/javascripts/blob_edit/blob_edit_header.js +++ b/app/assets/javascripts/blob_edit/blob_edit_header.js @@ -26,6 +26,7 @@ export default function initBlobEditHeader(editor) { newMergeRequestPath, forkProjectId, forkProjectPath, + nextForkBranchName, } = el.dataset; return new Vue({ @@ -44,6 +45,7 @@ export default function initBlobEditHeader(editor) { newMergeRequestPath, forkProjectId, forkProjectPath, + nextForkBranchName, emptyRepo: parseBoolean(emptyRepo), canPushCode: parseBoolean(canPushCode), canPushToBranch: parseBoolean(canPushToBranch), diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 37fce2c2cf4e54..47cd0aec770b9f 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -52,6 +52,7 @@ export default { 'newMergeRequestPath', 'forkProjectId', 'forkProjectPath', + 'nextForkBranchName', ], data() { return { @@ -204,13 +205,17 @@ export default { } }, async handleEditFormSubmit(formData) { - const originalFormData = prepareEditFormData(formData, { + let originalFormData = prepareEditFormData(formData, { fileContent: this.fileContent, filePath: this.filePath, lastCommitSha: this.lastCommitSha, fromMergeRequestIid: this.fromMergeRequestIid, }); + if (this.isWorkingOnFork()) { + originalFormData = { ...originalFormData, branch_name: this.nextForkBranchName }; + } + try { const response = this.glFeatures.blobEditRefactor ? await this.editBlob(originalFormData) @@ -272,10 +277,10 @@ export default { if (this.shouldRedirectToExistingMR(resultingBranch)) { this.redirectToExistingMergeRequest(url); - } else if (this.shouldCreateNewMR(formData, isNewBranch)) { - this.redirectToCreateMergeRequest(resultingBranch); } else if (this.isWorkingOnFork()) { this.redirectToForkMergeRequest(cleanUrl, resultingBranch); + } else if (this.shouldCreateNewMR(formData, isNewBranch)) { + this.redirectToCreateMergeRequest(resultingBranch); } else { this.redirectToBlobWithAlert({ cleanUrl, diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 11f4b3efa73107..38fc03cd3f6546 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -345,7 +345,8 @@ def edit_blob_app_data(project, id, blob, ref, action) project_path: project.full_path, new_merge_request_path: project_new_merge_request_path(project), fork_project_id: edit_blob_fork_project_id(project, ref), - fork_project_path: edit_blob_fork_project_path(project) + fork_project_path: edit_blob_fork_project_path(project), + next_fork_branch_name: edit_blob_fork_project(project)&.repository&.next_branch('patch') } end @@ -382,6 +383,12 @@ def edit_blob_fork_project_path(project) namespace_project_path(current_user, fork_of_project) end + def edit_blob_fork_project(project) + return unless current_user + + current_user&.fork_of(project) + end + def archive_download_links(project, ref, archive_prefix) Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt| { diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 931d193d5f9ee7..32b45cde26a417 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -277,6 +277,7 @@ def expect_fork_status wait_for_requests expect(page).to have_content('New commit message') + expect(page).to have_css 'div.branch-selector', text: 'patch-1' end context 'when the user already had a fork of the project', :js do diff --git a/spec/frontend/repository/pages/blob_edit_header_spec.js b/spec/frontend/repository/pages/blob_edit_header_spec.js index af4096b1c11f54..ace5ae3cd2f406 100644 --- a/spec/frontend/repository/pages/blob_edit_header_spec.js +++ b/spec/frontend/repository/pages/blob_edit_header_spec.js @@ -58,6 +58,7 @@ describe('BlobEditHeader', () => { newMergeRequestPath: 'merge_request/new/123', forkProjectId: null, forkProjectPath: null, + nextForkBranchName: null, ...provided, glFeatures: { blobEditRefactor: true, ...glFeatures }, }, @@ -422,12 +423,13 @@ describe('BlobEditHeader', () => { canPushToBranch: false, forkProjectId: 456, forkProjectPath: 'user/gitlab-fork', + nextForkBranchName: 'patch-1', }, }); clickCommitChangesButton(); mock.onPut().replyOnce(HTTP_STATUS_OK, { - branch: 'feature', + branch: 'patch-1', file_path: 'test.js', }); @@ -440,8 +442,9 @@ describe('BlobEditHeader', () => { const putData = JSON.parse(mock.history.put[0].data); expect(putData.content).toBe(content); + // this should actually redirect to source project expect(visitUrlSpy).toHaveBeenCalledWith( - 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=feature&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', + 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=patch-1&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', ); expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); }); @@ -455,6 +458,7 @@ describe('BlobEditHeader', () => { canPushToBranch: false, forkProjectId: 456, forkProjectPath: 'user/gitlab-fork', + nextForkBranchName: 'patch-1', }, }); mockEditor.filepathFormMediator.$filenameInput.val.mockReturnValue('renamed_test.js'); @@ -474,7 +478,7 @@ describe('BlobEditHeader', () => { expect(mock.history.post).toHaveLength(1); expect(mock.history.post[0].url).toBe('/api/v4/projects/456/repository/commits'); expect(visitUrlSpy).toHaveBeenCalledWith( - 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=feature&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', + 'http://test.host/user/gitlab-fork/-/merge_requests/new?merge_request%5Bsource_project_id%5D=456&merge_request%5Bsource_branch%5D=patch-1&merge_request%5Btarget_project_id%5D=123&merge_request%5Btarget_branch%5D=main', ); expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); }); diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 4a4d35b42ca87e..f961f75e29c64c 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -517,6 +517,37 @@ }) end + it 'returns data related to update action in a forked project' do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + allow(blob).to receive(:stored_externally?).and_return(false) + allow(project).to receive(:branch_allows_collaboration?).with(user, ref).and_return(false) + assign(:last_commit_sha, '782426692977b2cedb4452ee6501a404410f9b00') + + app_data = helper.edit_blob_app_data(project, id, blob, ref, "update") + + expect(app_data).to include({ + action: 'update', + update_path: project_update_blob_path(project, id), + cancel_path: project_blob_path(project, id), + original_branch: ref, + target_branch: ref, + can_push_code: 'true', + can_push_to_branch: 'true', + empty_repo: 'false', + blob_name: blob.name, + branch_allows_collaboration: 'false', + last_commit_sha: '782426692977b2cedb4452ee6501a404410f9b00', + project_id: project.id, + project_path: project.full_path, + new_merge_request_path: project_new_merge_request_path(project), + fork_project_id: 999, + next_fork_branch_name: 'patch-1' + }) + # fork_project_path is created based on user_id and project_id and different with every test run + expect(app_data[:fork_project_path]).not_to be_nil + end + it 'returns data related to create action' do expect(helper.edit_blob_app_data(project, id, blob, ref, "create")).to include({ action: 'create', @@ -644,6 +675,43 @@ end end + describe '#edit_blob_fork_project' do + context 'when user is logged in' do + context 'when user has forked the project' do + it 'returns the fork project' do + fork_project = build_stubbed(:project, id: 999) + allow(user).to receive(:fork_of).with(project).and_return(fork_project) + + fork = helper.send(:edit_blob_fork_project, project) + + expect(fork).to eq(fork_project) + end + end + + context 'when user has not forked the project' do + it 'returns nil' do + allow(user).to receive(:fork_of).with(project).and_return(nil) + + fork = helper.send(:edit_blob_fork_project, project) + + expect(fork).to be_nil + end + end + end + + context 'when user is not logged in' do + before do + allow(helper).to receive(:current_user).and_return(nil) + end + + it 'returns nil' do + fork = helper.send(:edit_blob_fork_project, project) + + expect(fork).to be_nil + end + end + end + describe '#edit_blob_fork_project_path' do context 'when user is logged in' do context 'when user has forked the project and cannot create groups' do -- GitLab From 0512af3043542130be9a56630a71827634bc81c8 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 11 Dec 2025 11:37:58 +0100 Subject: [PATCH 08/11] Add test case for renaming a file in a fork We use different API calls for just editing the file content and for renaming the file. Adding an extra case to make sure we cover both execution paths. --- .../projects/files/user_edits_files_spec.rb | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 32b45cde26a417..b8adc23cbd79a4 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -280,6 +280,30 @@ def expect_fork_status expect(page).to have_css 'div.branch-selector', text: 'patch-1' end + it 'commits a renamed file in a forked project', :sidekiq_might_not_need_inline do + click_link('.gitignore') + edit_in_single_file_editor + + expect_fork_prompt + click_link_or_button('Fork') + + edit_in_single_file_editor + + fill_in _('File path'), with: '.gitignore-example' + + click_button('Commit changes') + + within_testid('commit-change-modal') do + fill_in(:commit_message, with: 'New commit message', visible: true) + click_button('Commit changes') + end + + wait_for_requests + + expect(page).to have_content('New commit message') + expect(page).to have_css 'div.branch-selector', text: 'patch-1' + end + context 'when the user already had a fork of the project', :js do let!(:forked_project) { fork_project(project2, user, namespace: user.namespace, repository: true) } -- GitLab From d6270996d6258d145d68125c36287d6ff0a8aa8c Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Tue, 16 Dec 2025 13:51:03 +0100 Subject: [PATCH 09/11] Simplify url operations --- .../repository/pages/blob_edit_header.vue | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 47cd0aec770b9f..c24c0f10a9fa5d 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -150,17 +150,15 @@ export default { return this.editBlobWithUpdateFileApi(originalFormData); }, editBlobWithUpdateFileApi(originalFormData) { - const targetProjectId = this.getTargetProjectId; - const url = buildApiUrl(this.$options.UPDATE_FILE_PATH) - .replace(':id', targetProjectId) + .replace(':id', this.getTargetProjectId) .replace(':file_path', encodeURIComponent(this.originalFilePath)); const data = { ...prepareDataForApiEdit(originalFormData), content: originalFormData.file, file_path: originalFormData.file_path, - id: targetProjectId, + id: this.getTargetProjectId, last_commit_id: originalFormData.last_commit_sha, }; @@ -212,7 +210,7 @@ export default { fromMergeRequestIid: this.fromMergeRequestIid, }); - if (this.isWorkingOnFork()) { + if (!this.canPushToBranch) { originalFormData = { ...originalFormData, branch_name: this.nextForkBranchName }; } @@ -272,18 +270,17 @@ export default { handleEditBlobSuccess(responseData, formData) { const resultingBranch = this.getResultingBranch(responseData, formData); const isNewBranch = this.originalBranch !== resultingBranch; - const url = new URL(window.location.href); - const cleanUrl = removeParams(['from_merge_request_iid'], url.toString()); + const urlString = new URL(window.location.href).toString(); if (this.shouldRedirectToExistingMR(resultingBranch)) { - this.redirectToExistingMergeRequest(url); - } else if (this.isWorkingOnFork()) { - this.redirectToForkMergeRequest(cleanUrl, resultingBranch); + this.redirectToExistingMergeRequest(urlString); + } else if (!this.canPushToBranch) { + this.redirectToForkMergeRequest(urlString, resultingBranch); } else if (this.shouldCreateNewMR(formData, isNewBranch)) { this.redirectToCreateMergeRequest(resultingBranch); } else { this.redirectToBlobWithAlert({ - cleanUrl, + urlString, resultingBranch, responseData, formData, @@ -301,13 +298,14 @@ export default { shouldCreateNewMR(formData, isNewBranch) { return formData.create_merge_request && isNewBranch; }, - isWorkingOnFork() { - return !this.canPushToBranch; - }, redirectToExistingMergeRequest(url) { - const mrUrl = new URL(url.toString()); - mrUrl.pathname = joinPaths(this.projectPath, '/-/merge_requests/', this.fromMergeRequestIid); - const cleanUrl = removeParams(['from_merge_request_iid'], mrUrl.toString()); + const urlCopy = new URL(url); + urlCopy.pathname = joinPaths( + this.projectPath, + '/-/merge_requests/', + this.fromMergeRequestIid, + ); + const cleanUrl = removeParams(['from_merge_request_iid'], urlCopy.toString()); visitUrl(cleanUrl); }, redirectToCreateMergeRequest(resultingBranch) { @@ -317,9 +315,9 @@ export default { ); visitUrl(mrUrl); }, - redirectToForkMergeRequest(cleanUrl, resultingBranch) { - const mrUrl = new URL(cleanUrl); - mrUrl.pathname = joinPaths(this.getTargetProjectPath, '/-/merge_requests/new'); + redirectToForkMergeRequest(url, resultingBranch) { + const urlCopy = new URL(url); + urlCopy.pathname = joinPaths(this.getTargetProjectPath, '/-/merge_requests/new'); const mrParams = { 'merge_request[source_project_id]': this.getTargetProjectId, @@ -328,11 +326,12 @@ export default { 'merge_request[target_branch]': this.originalBranch, }; - const finalMrUrl = mergeUrlParams(mrParams, mrUrl.toString()); + const finalMrUrl = mergeUrlParams(mrParams, urlCopy.toString()); visitUrl(finalMrUrl); }, redirectToBlobWithAlert(options) { - const { cleanUrl, resultingBranch, responseData, formData, isNewBranch } = options; + const { url, resultingBranch, responseData, formData, isNewBranch } = options; + const cleanUrl = removeParams(['from_merge_request_iid'], url); const successPath = this.getUpdatePath(cleanUrl, { targetPath: this.getTargetProjectPath, branch: resultingBranch, -- GitLab From 8c869fbb4c787fd3fee271f79c14255858b8492b Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 18 Dec 2025 13:51:12 +0100 Subject: [PATCH 10/11] Unify authorization for files API Both POST and PUT endpoints now check if the user can contribute to the branch, not just the push permission. This fixes the scenario for a fork project that allows collaboration from source maintainers. Changelog: fixed --- lib/api/files.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/api/files.rb b/lib/api/files.rb index db50609bb4ca2e..e26eea2a4b8314 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -293,7 +293,7 @@ def filter_file_params!(params) end post ':id/repository/files/:file_path/authorize' do workhorse_authorize_commits_body_upload! do - authorize! :push_code, user_project + authorize_push_to_branch!(params[:branch]) end end @@ -305,10 +305,10 @@ def filter_file_params!(params) end post ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS, urgency: :low do require_gitlab_workhorse! - authorize! :push_code, user_project file_params = validate_file_params!(file_params_from_body_upload) file_params[:file_path] = params[:file_path] + authorize_push_to_branch!(file_params[:branch]) result = ::Files::CreateService.new(user_project, current_user, commit_params(file_params)).execute @@ -333,7 +333,7 @@ def filter_file_params!(params) end put ':id/repository/files/:file_path/authorize' do workhorse_authorize_commits_body_upload! do - authorize! :push_code, user_project + authorize_push_to_branch!(params[:branch]) end end @@ -345,10 +345,10 @@ def filter_file_params!(params) end put ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS, urgency: :low do require_gitlab_workhorse! - authorize_push_to_branch!(params[:branch]) file_params = validate_file_params!(file_params_from_body_upload) file_params[:file_path] = params[:file_path] + authorize_push_to_branch!(params[:branch]) begin result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute -- GitLab From 1ab8a7373487d1cfca9569a04740d45db3d9c9f7 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 18 Dec 2025 16:09:07 +0100 Subject: [PATCH 11/11] Get target project id nad path directly --- .../javascripts/blob_edit/blob_edit_header.js | 8 +++--- .../repository/pages/blob_edit_header.vue | 25 ++++++------------- app/helpers/blob_helper.rb | 19 +++++++++++--- .../repository/pages/blob_edit_header_spec.js | 12 ++++----- spec/helpers/blob_helper_spec.rb | 11 +++++--- 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/app/assets/javascripts/blob_edit/blob_edit_header.js b/app/assets/javascripts/blob_edit/blob_edit_header.js index f79b1e180d56fd..dcf512b2bc4063 100644 --- a/app/assets/javascripts/blob_edit/blob_edit_header.js +++ b/app/assets/javascripts/blob_edit/blob_edit_header.js @@ -24,8 +24,8 @@ export default function initBlobEditHeader(editor) { projectId, projectPath, newMergeRequestPath, - forkProjectId, - forkProjectPath, + targetProjectId, + targetProjectPath, nextForkBranchName, } = el.dataset; @@ -43,8 +43,8 @@ export default function initBlobEditHeader(editor) { projectId, projectPath, newMergeRequestPath, - forkProjectId, - forkProjectPath, + targetProjectId, + targetProjectPath, nextForkBranchName, emptyRepo: parseBoolean(emptyRepo), canPushCode: parseBoolean(canPushCode), diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index c24c0f10a9fa5d..d9f943ce74d59f 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -50,8 +50,8 @@ export default { 'projectId', 'projectPath', 'newMergeRequestPath', - 'forkProjectId', - 'forkProjectPath', + 'targetProjectId', + 'targetProjectPath', 'nextForkBranchName', ], data() { @@ -65,12 +65,6 @@ export default { }; }, computed: { - getTargetProjectId() { - return this.canPushToBranch ? this.projectId : this.forkProjectId || this.projectId; - }, - getTargetProjectPath() { - return this.canPushToBranch ? this.projectPath : this.forkProjectPath || this.projectPath; - }, updateModalId() { return uniqueId('update-modal'); }, @@ -151,24 +145,21 @@ export default { }, editBlobWithUpdateFileApi(originalFormData) { const url = buildApiUrl(this.$options.UPDATE_FILE_PATH) - .replace(':id', this.getTargetProjectId) + .replace(':id', this.targetProjectId) .replace(':file_path', encodeURIComponent(this.originalFilePath)); const data = { ...prepareDataForApiEdit(originalFormData), content: originalFormData.file, file_path: originalFormData.file_path, - id: this.getTargetProjectId, + id: this.targetProjectId, last_commit_id: originalFormData.last_commit_sha, }; return axios.put(url, data); }, editBlobWithCommitsApi(originalFormData) { - const url = buildApiUrl(this.$options.COMMIT_FILE_PATH).replace( - ':id', - this.getTargetProjectId, - ); + const url = buildApiUrl(this.$options.COMMIT_FILE_PATH).replace(':id', this.targetProjectId); const action = { action: 'move', @@ -317,10 +308,10 @@ export default { }, redirectToForkMergeRequest(url, resultingBranch) { const urlCopy = new URL(url); - urlCopy.pathname = joinPaths(this.getTargetProjectPath, '/-/merge_requests/new'); + urlCopy.pathname = joinPaths(this.targetProjectPath, '/-/merge_requests/new'); const mrParams = { - 'merge_request[source_project_id]': this.getTargetProjectId, + 'merge_request[source_project_id]': this.targetProjectId, 'merge_request[source_branch]': resultingBranch, 'merge_request[target_project_id]': this.projectId, 'merge_request[target_branch]': this.originalBranch, @@ -333,7 +324,7 @@ export default { const { url, resultingBranch, responseData, formData, isNewBranch } = options; const cleanUrl = removeParams(['from_merge_request_iid'], url); const successPath = this.getUpdatePath(cleanUrl, { - targetPath: this.getTargetProjectPath, + targetPath: this.targetProjectPath, branch: resultingBranch, filePath: responseData.file_path || formData.file_path, }); diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 38fc03cd3f6546..44d4b1bb2350ee 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -328,6 +328,7 @@ def vue_blob_header_app_data(project, blob, ref) def edit_blob_app_data(project, id, blob, ref, action) is_update = action == 'update' is_create = action == 'create' + can_push_to_branch = project.present(current_user: current_user).can_current_user_push_to_branch?(ref) { action: action.to_s, @@ -336,7 +337,7 @@ def edit_blob_app_data(project, id, blob, ref, action) original_branch: ref, target_branch: selected_branch, can_push_code: can?(current_user, :push_code, project).to_s, - can_push_to_branch: project.present(current_user: current_user).can_current_user_push_to_branch?(ref).to_s, + can_push_to_branch: can_push_to_branch.to_s, empty_repo: project.empty_repo?.to_s, blob_name: is_update ? blob.name : nil, branch_allows_collaboration: project.branch_allows_collaboration?(current_user, ref).to_s, @@ -344,14 +345,26 @@ def edit_blob_app_data(project, id, blob, ref, action) project_id: project.id, project_path: project.full_path, new_merge_request_path: project_new_merge_request_path(project), - fork_project_id: edit_blob_fork_project_id(project, ref), - fork_project_path: edit_blob_fork_project_path(project), + target_project_id: edit_blob_target_project_id(project, ref, can_push_to_branch), + target_project_path: edit_blob_target_project_path(project, can_push_to_branch), next_fork_branch_name: edit_blob_fork_project(project)&.repository&.next_branch('patch') } end private + def edit_blob_target_project_id(project, ref, can_push_to_branch) + return project.id if can_push_to_branch + + edit_blob_fork_project_id(project, ref) || project.id + end + + def edit_blob_target_project_path(project, can_push_to_branch) + return project.full_path if can_push_to_branch + + edit_blob_fork_project_path(project) || project.full_path + end + def edit_blob_update_path(project, id, is_update, is_create) if is_update project_update_blob_path(project, id) diff --git a/spec/frontend/repository/pages/blob_edit_header_spec.js b/spec/frontend/repository/pages/blob_edit_header_spec.js index ace5ae3cd2f406..8d157dc442ca00 100644 --- a/spec/frontend/repository/pages/blob_edit_header_spec.js +++ b/spec/frontend/repository/pages/blob_edit_header_spec.js @@ -56,8 +56,8 @@ describe('BlobEditHeader', () => { projectId: 123, projectPath: 'gitlab-org/gitlab', newMergeRequestPath: 'merge_request/new/123', - forkProjectId: null, - forkProjectPath: null, + targetProjectId: 123, + targetProjectPath: 'gitlab-org/gitlab', nextForkBranchName: null, ...provided, glFeatures: { blobEditRefactor: true, ...glFeatures }, @@ -421,8 +421,8 @@ describe('BlobEditHeader', () => { glFeatures: { blobEditRefactor: true }, provided: { canPushToBranch: false, - forkProjectId: 456, - forkProjectPath: 'user/gitlab-fork', + targetProjectId: 456, + targetProjectPath: 'user/gitlab-fork', nextForkBranchName: 'patch-1', }, }); @@ -456,8 +456,8 @@ describe('BlobEditHeader', () => { glFeatures: { blobEditRefactor: true }, provided: { canPushToBranch: false, - forkProjectId: 456, - forkProjectPath: 'user/gitlab-fork', + targetProjectId: 456, + targetProjectPath: 'user/gitlab-fork', nextForkBranchName: 'patch-1', }, }); diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index f961f75e29c64c..9d1623df2b0088 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -523,6 +523,10 @@ allow(blob).to receive(:stored_externally?).and_return(false) allow(project).to receive(:branch_allows_collaboration?).with(user, ref).and_return(false) assign(:last_commit_sha, '782426692977b2cedb4452ee6501a404410f9b00') + # User cannot push to the original project's branch + project_presenter = instance_double(ProjectPresenter) + allow(project).to receive(:present).and_return(project_presenter) + allow(project_presenter).to receive(:can_current_user_push_to_branch?).with(ref).and_return(false) app_data = helper.edit_blob_app_data(project, id, blob, ref, "update") @@ -533,7 +537,7 @@ original_branch: ref, target_branch: ref, can_push_code: 'true', - can_push_to_branch: 'true', + can_push_to_branch: 'false', empty_repo: 'false', blob_name: blob.name, branch_allows_collaboration: 'false', @@ -541,11 +545,10 @@ project_id: project.id, project_path: project.full_path, new_merge_request_path: project_new_merge_request_path(project), - fork_project_id: 999, + target_project_id: 999, + target_project_path: namespace_project_path(user, fork_project), next_fork_branch_name: 'patch-1' }) - # fork_project_path is created based on user_id and project_id and different with every test run - expect(app_data[:fork_project_path]).not_to be_nil end it 'returns data related to create action' do -- GitLab