Make "update file" API can create a new file when SHA is not set (#35738)

Fix #19008, use GitHub's behavior (empty SHA to create a new file)
This commit is contained in:
wxiaoguang 2025-10-24 12:46:54 +08:00 committed by GitHub
parent 397d666432
commit 9a73a1fb83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 58 additions and 50 deletions

View File

@ -24,13 +24,6 @@ type FileOptions struct {
Signoff bool `json:"signoff"` Signoff bool `json:"signoff"`
} }
type FileOptionsWithSHA struct {
FileOptions
// the blob ID (SHA) for the file that already exists, it is required for changing existing files
// required: true
SHA string `json:"sha" binding:"Required"`
}
func (f *FileOptions) GetFileOptions() *FileOptions { func (f *FileOptions) GetFileOptions() *FileOptions {
return f return f
} }
@ -41,7 +34,7 @@ type FileOptionsInterface interface {
var _ FileOptionsInterface = (*FileOptions)(nil) var _ FileOptionsInterface = (*FileOptions)(nil)
// CreateFileOptions options for creating files // CreateFileOptions options for creating a file
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) // Note: `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 CreateFileOptions struct { type CreateFileOptions struct {
FileOptions FileOptions
@ -50,16 +43,21 @@ type CreateFileOptions struct {
ContentBase64 string `json:"content"` ContentBase64 string `json:"content"`
} }
// DeleteFileOptions options for deleting files (used for other File structs below) // DeleteFileOptions options for deleting a file
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) // Note: `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 DeleteFileOptions struct { type DeleteFileOptions struct {
FileOptionsWithSHA FileOptions
// the blob ID (SHA) for the file to delete
// required: true
SHA string `json:"sha" binding:"Required"`
} }
// UpdateFileOptions options for updating files // UpdateFileOptions options for updating or creating a file
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) // Note: `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 UpdateFileOptions struct { type UpdateFileOptions struct {
FileOptionsWithSHA FileOptions
// the blob ID (SHA) for the file that already exists to update, or leave it empty to create a new file
SHA string `json:"sha"`
// content must be base64 encoded // content must be base64 encoded
// required: true // required: true
ContentBase64 string `json:"content"` ContentBase64 string `json:"content"`

View File

@ -525,7 +525,7 @@ func CreateFile(ctx *context.APIContext) {
func UpdateFile(ctx *context.APIContext) { func UpdateFile(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile
// --- // ---
// summary: Update a file in a repository // summary: Update a file in a repository if SHA is set, or create the file if SHA is not set
// consumes: // consumes:
// - application/json // - application/json
// produces: // produces:
@ -554,6 +554,8 @@ func UpdateFile(ctx *context.APIContext) {
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/FileResponse" // "$ref": "#/responses/FileResponse"
// "201":
// "$ref": "#/responses/FileResponse"
// "403": // "403":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "404": // "404":
@ -572,8 +574,9 @@ func UpdateFile(ctx *context.APIContext) {
ctx.APIError(http.StatusUnprocessableEntity, err) ctx.APIError(http.StatusUnprocessableEntity, err)
return return
} }
willCreate := apiOpts.SHA == ""
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
Operation: "update", Operation: util.Iif(willCreate, "create", "update"),
ContentReader: contentReader, ContentReader: contentReader,
SHA: apiOpts.SHA, SHA: apiOpts.SHA,
FromTreePath: apiOpts.FromPath, FromTreePath: apiOpts.FromPath,
@ -587,7 +590,7 @@ func UpdateFile(ctx *context.APIContext) {
handleChangeRepoFilesError(ctx, err) handleChangeRepoFilesError(ctx, err)
} else { } else {
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
ctx.JSON(http.StatusOK, fileResponse) ctx.JSON(util.Iif(willCreate, http.StatusCreated, http.StatusOK), fileResponse)
} }
} }

View File

@ -7634,7 +7634,7 @@
"tags": [ "tags": [
"repository" "repository"
], ],
"summary": "Update a file in a repository", "summary": "Update a file in a repository if SHA is set, or create the file if SHA is not set",
"operationId": "repoUpdateFile", "operationId": "repoUpdateFile",
"parameters": [ "parameters": [
{ {
@ -7671,6 +7671,9 @@
"200": { "200": {
"$ref": "#/responses/FileResponse" "$ref": "#/responses/FileResponse"
}, },
"201": {
"$ref": "#/responses/FileResponse"
},
"403": { "403": {
"$ref": "#/responses/error" "$ref": "#/responses/error"
}, },
@ -22886,7 +22889,7 @@
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"CreateFileOptions": { "CreateFileOptions": {
"description": "CreateFileOptions options for creating files\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)", "description": "CreateFileOptions options for creating a file\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", "type": "object",
"required": [ "required": [
"content" "content"
@ -23904,7 +23907,7 @@
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"DeleteFileOptions": { "DeleteFileOptions": {
"description": "DeleteFileOptions options for deleting files (used for other File structs below)\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)", "description": "DeleteFileOptions options for deleting a file\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", "type": "object",
"required": [ "required": [
"sha" "sha"
@ -23940,7 +23943,7 @@
"x-go-name": "NewBranchName" "x-go-name": "NewBranchName"
}, },
"sha": { "sha": {
"description": "the blob ID (SHA) for the file that already exists, it is required for changing existing files", "description": "the blob ID (SHA) for the file to delete",
"type": "string", "type": "string",
"x-go-name": "SHA" "x-go-name": "SHA"
}, },
@ -28700,10 +28703,9 @@
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"UpdateFileOptions": { "UpdateFileOptions": {
"description": "UpdateFileOptions options for updating files\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)", "description": "UpdateFileOptions options for updating or creating a file\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", "type": "object",
"required": [ "required": [
"sha",
"content" "content"
], ],
"properties": { "properties": {
@ -28747,7 +28749,7 @@
"x-go-name": "NewBranchName" "x-go-name": "NewBranchName"
}, },
"sha": { "sha": {
"description": "the blob ID (SHA) for the file that already exists, it is required for changing existing files", "description": "the blob ID (SHA) for the file that already exists to update, or leave it empty to create a new file",
"type": "string", "type": "string",
"x-go-name": "SHA" "x-go-name": "SHA"
}, },

View File

@ -20,21 +20,19 @@ import (
func getDeleteFileOptions() *api.DeleteFileOptions { func getDeleteFileOptions() *api.DeleteFileOptions {
return &api.DeleteFileOptions{ return &api.DeleteFileOptions{
FileOptionsWithSHA: api.FileOptionsWithSHA{ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
FileOptions: api.FileOptions{ FileOptions: api.FileOptions{
BranchName: "master", BranchName: "master",
NewBranchName: "master", NewBranchName: "master",
Message: "Removing the file new/file.txt", Message: "Removing the file new/file.txt",
Author: api.Identity{ Author: api.Identity{
Name: "John Doe", Name: "John Doe",
Email: "johndoe@example.com", Email: "johndoe@example.com",
}, },
Committer: api.Identity{ Committer: api.Identity{
Name: "Jane Doe", Name: "Jane Doe",
Email: "janedoe@example.com", Email: "janedoe@example.com",
},
}, },
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
}, },
} }
} }

View File

@ -28,21 +28,19 @@ func getUpdateFileOptions() *api.UpdateFileOptions {
content := "This is updated text" content := "This is updated text"
contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) contentEncoded := base64.StdEncoding.EncodeToString([]byte(content))
return &api.UpdateFileOptions{ return &api.UpdateFileOptions{
FileOptionsWithSHA: api.FileOptionsWithSHA{ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
FileOptions: api.FileOptions{ FileOptions: api.FileOptions{
BranchName: "master", BranchName: "master",
NewBranchName: "master", NewBranchName: "master",
Message: "My update of new/file.txt", Message: "My update of new/file.txt",
Author: api.Identity{ Author: api.Identity{
Name: "John Doe", Name: "John Doe",
Email: "johndoe@example.com", Email: "johndoe@example.com",
}, },
Committer: api.Identity{ Committer: api.Identity{
Name: "Anne Doe", Name: "Anne Doe",
Email: "annedoe@example.com", Email: "annedoe@example.com",
},
}, },
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
}, },
ContentBase64: contentEncoded, ContentBase64: contentEncoded,
} }
@ -180,6 +178,15 @@ func TestAPIUpdateFile(t *testing.T) {
assert.Equal(t, expectedDownloadURL, *fileResponse.Content.DownloadURL) assert.Equal(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
assert.Equal(t, updateFileOptions.Message+"\n", fileResponse.Commit.Message) assert.Equal(t, updateFileOptions.Message+"\n", fileResponse.Commit.Message)
// Test updating a file without SHA (should create the file)
updateFileOptions = getUpdateFileOptions()
updateFileOptions.SHA = ""
req = NewRequestWithJSON(t, "PUT", "/api/v1/repos/user2/repo1/contents/update-create.txt", &updateFileOptions).AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &fileResponse)
assert.Equal(t, "08bd14b2e2852529157324de9c226b3364e76136", fileResponse.Content.SHA)
assert.Equal(t, setting.AppURL+"user2/repo1/raw/branch/master/update-create.txt", *fileResponse.Content.DownloadURL)
// Test updating a file and renaming it // Test updating a file and renaming it
updateFileOptions = getUpdateFileOptions() updateFileOptions = getUpdateFileOptions()
updateFileOptions.BranchName = repo1.DefaultBranch updateFileOptions.BranchName = repo1.DefaultBranch