mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-05-31 11:52:10 +00:00
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
This commit is contained in:
parent
245f2c08db
commit
275d4b7e3f
16 changed files with 1309 additions and 778 deletions
|
@ -1173,6 +1173,7 @@ func Routes(ctx gocontext.Context) *web.Route {
|
|||
m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(auth_model.AccessTokenScopeRepo), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch)
|
||||
m.Group("/contents", func() {
|
||||
m.Get("", repo.GetContentsList)
|
||||
m.Post("", reqToken(auth_model.AccessTokenScopeRepo), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles)
|
||||
m.Get("/*", repo.GetContents)
|
||||
m.Group("/*", func() {
|
||||
m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile)
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
|
@ -407,6 +408,96 @@ func canReadFiles(r *context.Repository) bool {
|
|||
return r.Permission.CanRead(unit.TypeCode)
|
||||
}
|
||||
|
||||
// ChangeFiles handles API call for creating or updating multiple files
|
||||
func ChangeFiles(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles
|
||||
// ---
|
||||
// summary: Create or update multiple files in a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ChangeFilesOptions"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/FilesResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
|
||||
apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions)
|
||||
|
||||
if apiOpts.BranchName == "" {
|
||||
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
files := []*files_service.ChangeRepoFile{}
|
||||
for _, file := range apiOpts.Files {
|
||||
changeRepoFile := &files_service.ChangeRepoFile{
|
||||
Operation: file.Operation,
|
||||
TreePath: file.Path,
|
||||
FromTreePath: file.FromPath,
|
||||
Content: file.Content,
|
||||
SHA: file.SHA,
|
||||
}
|
||||
files = append(files, changeRepoFile)
|
||||
}
|
||||
|
||||
opts := &files_service.ChangeRepoFilesOptions{
|
||||
Files: files,
|
||||
Message: apiOpts.Message,
|
||||
OldBranch: apiOpts.BranchName,
|
||||
NewBranch: apiOpts.NewBranchName,
|
||||
Committer: &files_service.IdentityOptions{
|
||||
Name: apiOpts.Committer.Name,
|
||||
Email: apiOpts.Committer.Email,
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
Name: apiOpts.Author.Name,
|
||||
Email: apiOpts.Author.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: apiOpts.Dates.Author,
|
||||
Committer: apiOpts.Dates.Committer,
|
||||
},
|
||||
Signoff: apiOpts.Signoff,
|
||||
}
|
||||
if opts.Dates.Author.IsZero() {
|
||||
opts.Dates.Author = time.Now()
|
||||
}
|
||||
if opts.Dates.Committer.IsZero() {
|
||||
opts.Dates.Committer = time.Now()
|
||||
}
|
||||
|
||||
if opts.Message == "" {
|
||||
opts.Message = changeFilesCommitMessage(ctx, files)
|
||||
}
|
||||
|
||||
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
|
||||
handleCreateOrUpdateFileError(ctx, err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusCreated, filesResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFile handles API call for creating a file
|
||||
func CreateFile(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile
|
||||
|
@ -453,11 +544,15 @@ func CreateFile(ctx *context.APIContext) {
|
|||
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
opts := &files_service.UpdateRepoFileOptions{
|
||||
Content: apiOpts.Content,
|
||||
IsNewFile: true,
|
||||
opts := &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ctx.Params("*"),
|
||||
Content: apiOpts.Content,
|
||||
},
|
||||
},
|
||||
Message: apiOpts.Message,
|
||||
TreePath: ctx.Params("*"),
|
||||
OldBranch: apiOpts.BranchName,
|
||||
NewBranch: apiOpts.NewBranchName,
|
||||
Committer: &files_service.IdentityOptions{
|
||||
|
@ -482,12 +577,13 @@ func CreateFile(ctx *context.APIContext) {
|
|||
}
|
||||
|
||||
if opts.Message == "" {
|
||||
opts.Message = ctx.Tr("repo.editor.add", opts.TreePath)
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil {
|
||||
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
|
||||
handleCreateOrUpdateFileError(ctx, err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusCreated, fileResponse)
|
||||
}
|
||||
}
|
||||
|
@ -540,15 +636,19 @@ func UpdateFile(ctx *context.APIContext) {
|
|||
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
opts := &files_service.UpdateRepoFileOptions{
|
||||
Content: apiOpts.Content,
|
||||
SHA: apiOpts.SHA,
|
||||
IsNewFile: false,
|
||||
Message: apiOpts.Message,
|
||||
FromTreePath: apiOpts.FromPath,
|
||||
TreePath: ctx.Params("*"),
|
||||
OldBranch: apiOpts.BranchName,
|
||||
NewBranch: apiOpts.NewBranchName,
|
||||
opts := &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "update",
|
||||
Content: apiOpts.Content,
|
||||
SHA: apiOpts.SHA,
|
||||
FromTreePath: apiOpts.FromPath,
|
||||
TreePath: ctx.Params("*"),
|
||||
},
|
||||
},
|
||||
Message: apiOpts.Message,
|
||||
OldBranch: apiOpts.BranchName,
|
||||
NewBranch: apiOpts.NewBranchName,
|
||||
Committer: &files_service.IdentityOptions{
|
||||
Name: apiOpts.Committer.Name,
|
||||
Email: apiOpts.Committer.Email,
|
||||
|
@ -571,12 +671,13 @@ func UpdateFile(ctx *context.APIContext) {
|
|||
}
|
||||
|
||||
if opts.Message == "" {
|
||||
opts.Message = ctx.Tr("repo.editor.update", opts.TreePath)
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil {
|
||||
if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil {
|
||||
handleCreateOrUpdateFileError(ctx, err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusOK, fileResponse)
|
||||
}
|
||||
}
|
||||
|
@ -600,7 +701,7 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
|
|||
}
|
||||
|
||||
// Called from both CreateFile or UpdateFile to handle both
|
||||
func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoFileOptions) (*api.FileResponse, error) {
|
||||
func createOrUpdateFiles(ctx *context.APIContext, opts *files_service.ChangeRepoFilesOptions) (*api.FilesResponse, error) {
|
||||
if !canWriteFiles(ctx, opts.OldBranch) {
|
||||
return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{
|
||||
UserID: ctx.Doer.ID,
|
||||
|
@ -608,13 +709,45 @@ func createOrUpdateFile(ctx *context.APIContext, opts *files_service.UpdateRepoF
|
|||
}
|
||||
}
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(opts.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for _, file := range opts.Files {
|
||||
content, err := base64.StdEncoding.DecodeString(file.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Content = string(content)
|
||||
}
|
||||
opts.Content = string(content)
|
||||
|
||||
return files_service.CreateOrUpdateRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts)
|
||||
return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts)
|
||||
}
|
||||
|
||||
// format commit message if empty
|
||||
func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string {
|
||||
var (
|
||||
createFiles []string
|
||||
updateFiles []string
|
||||
deleteFiles []string
|
||||
)
|
||||
for _, file := range files {
|
||||
switch file.Operation {
|
||||
case "create":
|
||||
createFiles = append(createFiles, file.TreePath)
|
||||
case "update":
|
||||
updateFiles = append(updateFiles, file.TreePath)
|
||||
case "delete":
|
||||
deleteFiles = append(deleteFiles, file.TreePath)
|
||||
}
|
||||
}
|
||||
message := ""
|
||||
if len(createFiles) != 0 {
|
||||
message += ctx.Tr("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
|
||||
}
|
||||
if len(updateFiles) != 0 {
|
||||
message += ctx.Tr("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
|
||||
}
|
||||
if len(deleteFiles) != 0 {
|
||||
message += ctx.Tr("repo.editor.delete", strings.Join(deleteFiles, ", "))
|
||||
}
|
||||
return strings.Trim(message, "\n")
|
||||
}
|
||||
|
||||
// DeleteFile Delete a file in a repository
|
||||
|
@ -670,12 +803,17 @@ func DeleteFile(ctx *context.APIContext) {
|
|||
apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
opts := &files_service.DeleteRepoFileOptions{
|
||||
opts := &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "delete",
|
||||
SHA: apiOpts.SHA,
|
||||
TreePath: ctx.Params("*"),
|
||||
},
|
||||
},
|
||||
Message: apiOpts.Message,
|
||||
OldBranch: apiOpts.BranchName,
|
||||
NewBranch: apiOpts.NewBranchName,
|
||||
SHA: apiOpts.SHA,
|
||||
TreePath: ctx.Params("*"),
|
||||
Committer: &files_service.IdentityOptions{
|
||||
Name: apiOpts.Committer.Name,
|
||||
Email: apiOpts.Committer.Email,
|
||||
|
@ -698,10 +836,10 @@ func DeleteFile(ctx *context.APIContext) {
|
|||
}
|
||||
|
||||
if opts.Message == "" {
|
||||
opts.Message = ctx.Tr("repo.editor.delete", opts.TreePath)
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if fileResponse, err := files_service.DeleteRepoFile(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
if git.IsErrBranchNotExist(err) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
|
||||
ctx.Error(http.StatusNotFound, "DeleteFile", err)
|
||||
return
|
||||
|
@ -718,6 +856,7 @@ func DeleteFile(ctx *context.APIContext) {
|
|||
}
|
||||
ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,6 +116,9 @@ type swaggerParameterBodies struct {
|
|||
// in:body
|
||||
EditAttachmentOptions api.EditAttachmentOptions
|
||||
|
||||
// in:body
|
||||
ChangeFilesOptions api.ChangeFilesOptions
|
||||
|
||||
// in:body
|
||||
CreateFileOptions api.CreateFileOptions
|
||||
|
||||
|
|
|
@ -296,6 +296,13 @@ type swaggerFileResponse struct {
|
|||
Body api.FileResponse `json:"body"`
|
||||
}
|
||||
|
||||
// FilesResponse
|
||||
// swagger:response FilesResponse
|
||||
type swaggerFilesResponse struct {
|
||||
// in: body
|
||||
Body api.FilesResponse `json:"body"`
|
||||
}
|
||||
|
||||
// ContentsResponse
|
||||
// swagger:response ContentsResponse
|
||||
type swaggerContentsResponse struct {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue