diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 66afede218c65..5f52ee8a4323d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1423,6 +1423,7 @@ func Routes() *web.Router { m.Get("/tags/{sha}", repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) + m.Post("/diffpatch", mustEnableEditor, reqToken(), bind(api.ApplyDiffPatchFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) m.Get("/*", repo.GetContents) @@ -1434,7 +1435,6 @@ func Routes() *web.Router { m.Put("", bind(api.UpdateFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.UpdateFile) m.Delete("", bind(api.DeleteFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.DeleteFile) }) - m.Post("/diffpatch", bind(api.ApplyDiffPatchFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ApplyDiffPatch) }, mustEnableEditor, reqToken()) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) m.Group("/contents-ext", func() { diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go index e9f5cf5d908da..edb09fc08fa08 100644 --- a/routers/api/v1/repo/patch.go +++ b/routers/api/v1/repo/patch.go @@ -36,7 +36,7 @@ func ApplyDiffPatch(ctx *context.APIContext) { // in: body // required: true // schema: - // "$ref": "#/definitions/UpdateFileOptions" + // "$ref": "#/definitions/ApplyDiffPatchFileOptions" // responses: // "200": // "$ref": "#/responses/FileResponse" diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 4aba74b939613..b80a9c14ba027 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -121,6 +121,9 @@ type swaggerParameterBodies struct { // in:body GetFilesOptions api.GetFilesOptions + // in:body + ApplyDiffPatchFileOptions api.ApplyDiffPatchFileOptions + // in:body ChangeFilesOptions api.ChangeFilesOptions diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0df8356fd9c38..325f0b78a0564 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7844,7 +7844,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/UpdateFileOptions" + "$ref": "#/definitions/ApplyDiffPatchFileOptions" } } ], @@ -21645,6 +21645,54 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ApplyDiffPatchFileOptions": { + "description": "ApplyDiffPatchFileOptions options for applying a diff patch\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", + "type": "object", + "required": [ + "content" + ], + "properties": { + "author": { + "$ref": "#/definitions/Identity" + }, + "branch": { + "description": "branch (optional) is the base branch for the changes. If not supplied, the default branch is used", + "type": "string", + "x-go-name": "BranchName" + }, + "committer": { + "$ref": "#/definitions/Identity" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "dates": { + "$ref": "#/definitions/CommitDateOptions" + }, + "force_push": { + "description": "force_push (optional) will do a force-push if the new branch already exists", + "type": "boolean", + "x-go-name": "ForcePush" + }, + "message": { + "description": "message (optional) is the commit message of the changes. If not supplied, a default message will be used", + "type": "string", + "x-go-name": "Message" + }, + "new_branch": { + "description": "new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch", + "type": "string", + "x-go-name": "NewBranchName" + }, + "signoff": { + "description": "Add a Signed-off-by trailer by the committer at the end of the commit log message.", + "type": "boolean", + "x-go-name": "Signoff" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Attachment": { "description": "Attachment a generic attachment", "type": "object", diff --git a/tests/integration/api_repo_file_diffpatch_test.go b/tests/integration/api_repo_file_diffpatch_test.go new file mode 100644 index 0000000000000..e463027ed3c83 --- /dev/null +++ b/tests/integration/api_repo_file_diffpatch_test.go @@ -0,0 +1,89 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func getApplyDiffPatchFileOptions() *api.ApplyDiffPatchFileOptions { + return &api.ApplyDiffPatchFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + }, + Content: `diff --git a/patch-file-1.txt b/patch-file-1.txt +new file mode 100644 +index 0000000000..aaaaaaaaaa +--- /dev/null ++++ b/patch-file-1.txt +@@ -0,0 +1 @@ ++File 1 +`, + } +} + +func TestAPIApplyDiffPatchFileOptions(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + + session2 := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + session4 := loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session4, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/diffpatch", getApplyDiffPatchFileOptions()).AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusCreated) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.Nil(t, fileResponse.Content) + assert.NotEmpty(t, fileResponse.Commit.HTMLURL) + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/raw/patch-file-1.txt") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "File 1\n", resp.Body.String()) + + // Test creating a file in repo1 by user4 who does not have write access + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", user2.Name, repo16.Name), getApplyDiffPatchFileOptions()). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", user2.Name, repo16.Name), getApplyDiffPatchFileOptions()) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", user2.Name, repo16.Name), getApplyDiffPatchFileOptions()). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" where user2 is a collaborator + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", org3.Name, repo3.Name), getApplyDiffPatchFileOptions()). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" with no user token + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", org3.Name, repo3.Name), getApplyDiffPatchFileOptions()) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/diffpatch", user2.Name, repo1.Name), getApplyDiffPatchFileOptions()). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + }) +}