Skip to content

Commit 275d4b7

Browse files
authored
API endpoint for changing/creating/deleting multiple files (#24887)
This PR creates an API endpoint for creating/updating/deleting multiple files in one API call similar to the solution provided by [GitLab](https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions). To archive this, the CreateOrUpdateRepoFile and DeleteRepoFIle functions in files service are unified into one function supporting multiple files and actions. Resolves #14619
1 parent 245f2c0 commit 275d4b7

File tree

16 files changed

+1238
-707
lines changed

16 files changed

+1238
-707
lines changed

modules/structs/repo_file.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ func (o *UpdateFileOptions) Branch() string {
6464
return o.FileOptions.BranchName
6565
}
6666

67+
// ChangeFileOperation for creating, updating or deleting a file
68+
type ChangeFileOperation struct {
69+
// indicates what to do with the file
70+
// required: true
71+
// enum: create,update,delete
72+
Operation string `json:"operation" binding:"Required"`
73+
// path to the existing or new file
74+
Path string `json:"path" binding:"MaxSize(500)"`
75+
// content must be base64 encoded
76+
// required: true
77+
Content string `json:"content"`
78+
// sha is the SHA for the file that already exists, required for update, delete
79+
SHA string `json:"sha"`
80+
// old path of the file to move
81+
FromPath string `json:"from_path"`
82+
}
83+
84+
// ChangeFilesOptions options for creating, updating or deleting multiple files
85+
// 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)
86+
type ChangeFilesOptions struct {
87+
FileOptions
88+
Files []*ChangeFileOperation `json:"files"`
89+
}
90+
91+
// Branch returns branch name
92+
func (o *ChangeFilesOptions) Branch() string {
93+
return o.FileOptions.BranchName
94+
}
95+
6796
// FileOptionInterface provides a unified interface for the different file options
6897
type FileOptionInterface interface {
6998
Branch() string
@@ -126,6 +155,13 @@ type FileResponse struct {
126155
Verification *PayloadCommitVerification `json:"verification"`
127156
}
128157

158+
// FilesResponse contains information about multiple files from a repo
159+
type FilesResponse struct {
160+
Files []*ContentsResponse `json:"files"`
161+
Commit *FileCommitResponse `json:"commit"`
162+
Verification *PayloadCommitVerification `json:"verification"`
163+
}
164+
129165
// FileDeleteResponse contains information about a repo's file that was deleted
130166
type FileDeleteResponse struct {
131167
Content interface{} `json:"content"` // to be set to nil

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,7 @@ func Routes(ctx gocontext.Context) *web.Route {
11731173
m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(auth_model.AccessTokenScopeRepo), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch)
11741174
m.Group("/contents", func() {
11751175
m.Get("", repo.GetContentsList)
1176+
m.Post("", reqToken(auth_model.AccessTokenScopeRepo), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles)
11761177
m.Get("/*", repo.GetContents)
11771178
m.Group("/*", func() {
11781179
m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile)

routers/api/v1/repo/file.go

Lines changed: 167 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"io"
1313
"net/http"
1414
"path"
15+
"strings"
1516
"time"
1617

1718
"code.gitea.io/gitea/models"
@@ -407,6 +408,96 @@ func canReadFiles(r *context.Repository) bool {
407408
return r.Permission.CanRead(unit.TypeCode)
408409
}
409410

411+
// ChangeFiles handles API call for creating or updating multiple files
412+
func ChangeFiles(ctx *context.APIContext) {
413+
// swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles
414+
// ---
415+
// summary: Create or update multiple files in a repository
416+
// consumes:
417+
// - application/json
418+
// produces:
419+
// - application/json
420+
// parameters:
421+
// - name: owner
422+
// in: path
423+
// description: owner of the repo
424+
// type: string
425+
// required: true
426+
// - name: repo
427+
// in: path
428+
// description: name of the repo
429+
// type: string
430+
// required: true
431+
// - name: body
432+
// in: body
433+
// required: true
434+
// schema:
435+
// "$ref": "#/definitions/ChangeFilesOptions"
436+
// responses:
437+
// "201":
438+
// "$ref": "#/responses/FilesResponse"
439+
// "403":
440+
// "$ref": "#/responses/error"
441+
// "404":
442+
// "$ref": "#/responses/notFound"
443+
// "422":
444+
// "$ref": "#/responses/error"
445+
446+
apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions)
447+
448+
if apiOpts.BranchName == "" {
449+
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
450+
}
451+
452+
files := []*files_service.ChangeRepoFile{}
453+
for _, file := range apiOpts.Files {
454+
changeRepoFile := &files_service.ChangeRepoFile{
455+
Operation: file.Operation,
456+
TreePath: file.Path,
457+
FromTreePath: file.FromPath,
458+
Content: file.Content,
459+
SHA: file.SHA,
460+
}
461+
files = append(files, changeRepoFile)
462+
}
463+
464+
opts := &files_service.ChangeRepoFilesOptions{
465+
Files: files,
466+
Message: apiOpts.Message,
467+
OldBranch: apiOpts.BranchName,
468+
NewBranch: apiOpts.NewBranchName,
469+
Committer: &files_service.IdentityOptions{
470+
Name: apiOpts.Committer.Name,
471+
Email: apiOpts.Committer.Email,
472+
},
473+
Author: &files_service.IdentityOptions{
474+
Name: apiOpts.Author.Name,
475+
Email: apiOpts.Author.Email,
476+
},
477+
Dates: &files_service.CommitDateOptions{
478+
Author: apiOpts.Dates.Author,
479+
Committer: apiOpts.Dates.Committer,
480+
},
481+
Signoff: apiOpts.Signoff,
482+
}
483+
if opts.Dates.Author.IsZero() {
484+
opts.Dates.Author = time.Now()
485+
}
486+
if opts.Dates.Committer.IsZero() {
487+
opts.Dates.Committer = time.Now()
488+
}
489+
490+
if opts.Message == "" {
491+
opts.Message = changeFilesCommitMessage(ctx, files)
492+
}
493+
494+
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
495+
handleCreateOrUpdateFileError(ctx, err)
496+
} else {
497+
ctx.JSON(http.StatusCreated, filesResponse)
498+
}
499+
}
500+
410501
// CreateFile handles API call for creating a file
411502
func CreateFile(ctx *context.APIContext) {
412503
// swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile
@@ -453,11 +544,15 @@ func CreateFile(ctx *context.APIContext) {
453544
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
454545
}
455546

456-
opts := &files_service.UpdateRepoFileOptions{
457-
Content: apiOpts.Content,
458-
IsNewFile: true,
547+
opts := &files_service.ChangeRepoFilesOptions{
548+
Files: []*files_service.ChangeRepoFile{
549+
{
550+
Operation: "create",
551+
TreePath: ctx.Params("*"),
552+
Content: apiOpts.Content,
553+
},
554+
},
459555
Message: apiOpts.Message,
460-
TreePath: ctx.Params("*"),
461556
OldBranch: apiOpts.BranchName,
462557
NewBranch: apiOpts.NewBranchName,
463558
Committer: &files_service.IdentityOptions{
@@ -482,12 +577,13 @@ func CreateFile(ctx *context.APIContext) {
482577
}
483578

484579
if opts.Message == "" {
485-
opts.Message = ctx.Tr("repo.editor.add", opts.TreePath)
580+
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
486581
}
487582

488-
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil {
583+
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
489584
handleCreateOrUpdateFileError(ctx, err)
490585
} else {
586+
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
491587
ctx.JSON(http.StatusCreated, fileResponse)
492588
}
493589
}
@@ -540,15 +636,19 @@ func UpdateFile(ctx *context.APIContext) {
540636
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
541637
}
542638

543-
opts := &files_service.UpdateRepoFileOptions{
544-
Content: apiOpts.Content,
545-
SHA: apiOpts.SHA,
546-
IsNewFile: false,
547-
Message: apiOpts.Message,
548-
FromTreePath: apiOpts.FromPath,
549-
TreePath: ctx.Params("*"),
550-
OldBranch: apiOpts.BranchName,
551-
NewBranch: apiOpts.NewBranchName,
639+
opts := &files_service.ChangeRepoFilesOptions{
640+
Files: []*files_service.ChangeRepoFile{
641+
{
642+
Operation: "update",
643+
Content: apiOpts.Content,
644+
SHA: apiOpts.SHA,
645+
FromTreePath: apiOpts.FromPath,
646+
TreePath: ctx.Params("*"),
647+
},
648+
},
649+
Message: apiOpts.Message,
650+
OldBranch: apiOpts.BranchName,
651+
NewBranch: apiOpts.NewBranchName,
552652
Committer: &files_service.IdentityOptions{
553653
Name: apiOpts.Committer.Name,
554654
Email: apiOpts.Committer.Email,
@@ -571,12 +671,13 @@ func UpdateFile(ctx *context.APIContext) {
571671
}
572672

573673
if opts.Message == "" {
574-
opts.Message = ctx.Tr("repo.editor.update", opts.TreePath)
674+
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
575675
}
576676

577-
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil {
677+
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
578678
handleCreateOrUpdateFileError(ctx, err)
579679
} else {
680+
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
580681
ctx.JSON(http.StatusOK, fileResponse)
581682
}
582683
}
@@ -600,21 +701,53 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
600701
}
601702

602703
// Called from both CreateFile or UpdateFile to handle both
603-
func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoFileOptions) (*api.FileResponse, error) {
704+
func createOrUpdateFiles(ctx *context.APIContext, opts *files_service.ChangeRepoFilesOptions) (*api.FilesResponse, error) {
604705
if !canWriteFiles(ctx, opts.OldBranch) {
605706
return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{
606707
UserID: ctx.Doer.ID,
607708
RepoName: ctx.Repo.Repository.LowerName,
608709
}
609710
}
610711

611-
content, err := base64.StdEncoding.DecodeString(opts.Content)
612-
if err != nil {
613-
return nil, err
712+
for _, file := range opts.Files {
713+
content, err := base64.StdEncoding.DecodeString(file.Content)
714+
if err != nil {
715+
return nil, err
716+
}
717+
file.Content = string(content)
614718
}
615-
opts.Content = string(content)
616719

617-
return files_service.CreateOrUpdateRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts)
720+
return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts)
721+
}
722+
723+
// format commit message if empty
724+
func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string {
725+
var (
726+
createFiles []string
727+
updateFiles []string
728+
deleteFiles []string
729+
)
730+
for _, file := range files {
731+
switch file.Operation {
732+
case "create":
733+
createFiles = append(createFiles, file.TreePath)
734+
case "update":
735+
updateFiles = append(updateFiles, file.TreePath)
736+
case "delete":
737+
deleteFiles = append(deleteFiles, file.TreePath)
738+
}
739+
}
740+
message := ""
741+
if len(createFiles) != 0 {
742+
message += ctx.Tr("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
743+
}
744+
if len(updateFiles) != 0 {
745+
message += ctx.Tr("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
746+
}
747+
if len(deleteFiles) != 0 {
748+
message += ctx.Tr("repo.editor.delete", strings.Join(deleteFiles, ", "))
749+
}
750+
return strings.Trim(message, "\n")
618751
}
619752

620753
// DeleteFile Delete a file in a repository
@@ -670,12 +803,17 @@ func DeleteFile(ctx *context.APIContext) {
670803
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
671804
}
672805

673-
opts := &files_service.DeleteRepoFileOptions{
806+
opts := &files_service.ChangeRepoFilesOptions{
807+
Files: []*files_service.ChangeRepoFile{
808+
{
809+
Operation: "delete",
810+
SHA: apiOpts.SHA,
811+
TreePath: ctx.Params("*"),
812+
},
813+
},
674814
Message: apiOpts.Message,
675815
OldBranch: apiOpts.BranchName,
676816
NewBranch: apiOpts.NewBranchName,
677-
SHA: apiOpts.SHA,
678-
TreePath: ctx.Params("*"),
679817
Committer: &files_service.IdentityOptions{
680818
Name: apiOpts.Committer.Name,
681819
Email: apiOpts.Committer.Email,
@@ -698,10 +836,10 @@ func DeleteFile(ctx *context.APIContext) {
698836
}
699837

700838
if opts.Message == "" {
701-
opts.Message = ctx.Tr("repo.editor.delete", opts.TreePath)
839+
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
702840
}
703841

704-
if fileResponse, err := files_service.DeleteRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
842+
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
705843
if git.IsErrBranchNotExist(err) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
706844
ctx.Error(http.StatusNotFound, "DeleteFile", err)
707845
return
@@ -718,6 +856,7 @@ func DeleteFile(ctx *context.APIContext) {
718856
}
719857
ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
720858
} else {
859+
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
721860
ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
722861
}
723862
}

routers/api/v1/swagger/options.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ type swaggerParameterBodies struct {
116116
// in:body
117117
EditAttachmentOptions api.EditAttachmentOptions
118118

119+
// in:body
120+
ChangeFilesOptions api.ChangeFilesOptions
121+
119122
// in:body
120123
CreateFileOptions api.CreateFileOptions
121124

routers/api/v1/swagger/repo.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,13 @@ type swaggerFileResponse struct {
296296
Body api.FileResponse `json:"body"`
297297
}
298298

299+
// FilesResponse
300+
// swagger:response FilesResponse
301+
type swaggerFilesResponse struct {
302+
// in: body
303+
Body api.FilesResponse `json:"body"`
304+
}
305+
299306
// ContentsResponse
300307
// swagger:response ContentsResponse
301308
type swaggerContentsResponse struct {

0 commit comments

Comments
 (0)