Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API endpoint for changing/creating/deleting multiple files #24887

Merged
merged 16 commits into from
May 29, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions modules/structs/repo_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ func (o *UpdateFileOptions) Branch() string {
return o.FileOptions.BranchName
}

// ChangeFileOperation for creating, updating or deleting a file
type ChangeFileOperation struct {
// required: true
// enum: create,update,delete
Operation string `json:"operation" binding:"Required"`
// path to the existing or new file
Path string `json:"path" binding:"MaxSize(500)"`
// content must be base64 encoded
// required: true
Content string `json:"content"`
// sha is the SHA for the file that already exists
SHA string `json:"sha"`
// old path of the file to move
FromPath string `json:"from_path"`
}

// ChangeFilesOptions options for creating, updating or deleting multiple files
// 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 ChangeFilesOptions struct {
FileOptions
Files []*ChangeFileOperation `json:"files"`
}

// Branch returns branch name
func (o *ChangeFilesOptions) Branch() string {
return o.FileOptions.BranchName
}

// FileOptionInterface provides a unified interface for the different file options
type FileOptionInterface interface {
Branch() string
Expand Down Expand Up @@ -126,6 +154,13 @@ type FileResponse struct {
Verification *PayloadCommitVerification `json:"verification"`
}

// FilesResponse contains information about multiple files from a repo
type FilesResponse struct {
Files []*ContentsResponse `json:"files"`
Commit *FileCommitResponse `json:"commit"`
Verification *PayloadCommitVerification `json:"verification"`
}

// FileDeleteResponse contains information about a repo's file that was deleted
type FileDeleteResponse struct {
Content interface{} `json:"content"` // to be set to nil
Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
219 changes: 191 additions & 28 deletions routers/api/v1/repo/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"io"
"net/http"
"path"
"strings"
"time"

"code.gitea.io/gitea/models"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -482,12 +577,21 @@ 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 {
content := &api.ContentsResponse{}
if len(filesResponse.Files) > 0 {
content = filesResponse.Files[0]
}
fileResponse := &api.FileResponse{
Content: content,
Commit: filesResponse.Commit,
Verification: filesResponse.Verification,
}
ctx.JSON(http.StatusCreated, fileResponse)
}
}
Expand Down Expand Up @@ -540,15 +644,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,
Expand All @@ -571,12 +679,21 @@ 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 {
content := &api.ContentsResponse{}
if len(filesResponse.Files) > 0 {
content = filesResponse.Files[0]
}
fileResponse := &api.FileResponse{
Content: content,
Commit: filesResponse.Commit,
Verification: filesResponse.Verification,
}
ctx.JSON(http.StatusOK, fileResponse)
}
}
Expand All @@ -600,21 +717,53 @@ 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,
RepoName: ctx.Repo.Repository.LowerName,
}
}

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
Expand Down Expand Up @@ -670,12 +819,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,
Expand All @@ -698,10 +852,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
Expand All @@ -718,6 +872,15 @@ func DeleteFile(ctx *context.APIContext) {
}
ctx.Error(http.StatusInternalServerError, "DeleteFile", err)
} else {
content := &api.ContentsResponse{}
if len(filesResponse.Files) > 0 {
content = filesResponse.Files[0]
}
fileResponse := &api.FileResponse{
Content: content,
Commit: filesResponse.Commit,
Verification: filesResponse.Verification,
}
ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
}
}
Expand Down
3 changes: 3 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ type swaggerParameterBodies struct {
// in:body
EditAttachmentOptions api.EditAttachmentOptions

// in:body
ChangeFilesOptions api.ChangeFilesOptions

// in:body
CreateFileOptions api.CreateFileOptions

Expand Down
7 changes: 7 additions & 0 deletions routers/api/v1/swagger/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading