diff --git a/modules/git/blame.go b/modules/git/blame.go index fcbf18398..dfee86371 100644 --- a/modules/git/blame.go +++ b/modules/git/blame.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "regexp" + "time" "code.gitea.io/gitea/modules/process" ) @@ -22,6 +23,25 @@ type BlamePart struct { Lines []string } +type ApiBlameCommit struct { + ID string `json:"id"` + Author *Signature `json:"author"` + Commiter *Signature `json:"commiter"` + CommitMessage string `json:"commit_message"` + Parents []string `json:"parents"` + AuthoredTime time.Time `json:"authored_time"` + CommittedTime time.Time `json:"committed_time"` + CreatedTime time.Time `json:"created_time"` +} + +type ApiBlamePart struct { + Sha string `json:"-"` + Commit *ApiBlameCommit `json:"commit"` + CurrentNumber int `json:"current_number"` + EffectLine int `json:"effect_line"` + Lines []string `json:"lines"` +} + // BlameReader returns part of file blame one by one type BlameReader struct { cmd *exec.Cmd @@ -34,6 +54,95 @@ type BlameReader struct { var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})") +func GetBlameCommit(repo *Repository, sha string) *ApiBlameCommit { + commit, err := repo.GetCommit(sha) + var apiParents []string + for i := 0; i < commit.ParentCount(); i++ { + sha, _ := commit.ParentID(i) + apiParents = append(apiParents, sha.String()) + } + + if err != nil { + return &ApiBlameCommit{} + } else { + return &ApiBlameCommit{ + ID: sha, + Author: commit.Author, + Commiter: commit.Committer, + CommitMessage: commit.CommitMessage, + Parents: apiParents, + AuthoredTime: commit.Author.When, + CommittedTime: commit.Committer.When, + CreatedTime: commit.Committer.When, + } + } +} + +func (r *BlameReader) NextApiPart(repo *Repository) (*ApiBlamePart, error) { + var blamePart *ApiBlamePart + + reader := r.reader + effectLine := 0 + + if r.lastSha != nil { + blamePart = &ApiBlamePart{*r.lastSha, GetBlameCommit(repo, *r.lastSha), 0, effectLine, make([]string, 0)} + } + + var line []byte + var isPrefix bool + var err error + + for err != io.EOF { + line, isPrefix, err = reader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + + if len(line) == 0 { + // isPrefix will be false + continue + } + + lines := shaLineRegex.FindSubmatch(line) + if lines != nil { + sha1 := string(lines[1]) + + if blamePart == nil { + blamePart = &ApiBlamePart{sha1, GetBlameCommit(repo, sha1), 0, effectLine, make([]string, 0)} + } + + if blamePart.Sha != sha1 { + r.lastSha = &sha1 + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = reader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + return blamePart, nil + } + } else if line[0] == '\t' { + code := line[1:] + effectLine += 1 + blamePart.Lines = append(blamePart.Lines, string(code)) + } + blamePart.EffectLine = effectLine + + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = reader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + } + + r.lastSha = nil + + return blamePart, nil +} + // NextPart returns next part of blame (sequential code lines with the same commit) func (r *BlameReader) NextPart() (*BlamePart, error) { var blamePart *BlamePart diff --git a/modules/repofiles/file.go b/modules/repofiles/file.go index abd14b1db..7581552a4 100644 --- a/modules/repofiles/file.go +++ b/modules/repofiles/file.go @@ -15,6 +15,21 @@ import ( api "code.gitea.io/gitea/modules/structs" ) +func GetBatchFileResponseFromCommit(repo *models.Repository, commit *git.Commit, branch string, treeNames []string) (*api.BatchFileResponse, error) { + fileCommitResponse, _ := GetFileCommitResponse(repo, commit) + verification := GetPayloadCommitVerification(commit) + batchFileResponse := &api.BatchFileResponse{ + Commit: fileCommitResponse, + Verification: verification, + } + for _, treeName := range treeNames { + fileContent, _ := GetContents(repo, treeName, branch, false) + batchFileResponse.Contents = append(batchFileResponse.Contents, fileContent) + } + + return batchFileResponse, nil +} + // GetFileResponseFromCommit Constructs a FileResponse from a Commit object func GetFileResponseFromCommit(repo *models.Repository, commit *git.Commit, branch, treeName string) (*api.FileResponse, error) { fileContents, _ := GetContents(repo, treeName, branch, false) // ok if fails, then will be nil diff --git a/modules/repofiles/update.go b/modules/repofiles/update.go index 5b45479f3..cbb348e65 100644 --- a/modules/repofiles/update.go +++ b/modules/repofiles/update.go @@ -20,11 +20,30 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "github.com/gobwas/glob" stdcharset "golang.org/x/net/html/charset" "golang.org/x/text/transform" ) +type FileActionType int + +const ( + ActionTypeCreate FileActionType = iota + 1 + ActionTypeUpdate + ActionTypeDelete +) + +var fileActionTypes = map[string]FileActionType{ + "create": ActionTypeCreate, + "update": ActionTypeUpdate, + "delete": ActionTypeDelete, +} + +func ToFileActionType(name string) FileActionType { + return fileActionTypes[name] +} + // IdentityOptions for a person's identity like an author or committer type IdentityOptions struct { Name string @@ -54,6 +73,31 @@ type UpdateRepoFileOptions struct { Signoff bool } +type ExchangeFileOption struct { + FileChan chan BatchSingleFileOption + StopChan chan bool + ErrChan chan error +} + +type BatchSingleFileOption struct { + Content string + TreePath string + FromTreePath string + ActionType FileActionType +} +type BatchUpdateFileOptions struct { + Files []BatchSingleFileOption + LastCommitID string + OldBranch string + NewBranch string + Message string + SHA string + Author *IdentityOptions + Commiter *IdentityOptions + Dates *CommitDateOptions + Signoff bool +} + func detectEncodingAndBOM(entry *git.TreeEntry, repo *models.Repository) (string, bool) { reader, err := entry.Blob().DataAsync() if err != nil { @@ -263,7 +307,7 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up } else if changed { return nil, models.ErrCommitIDDoesNotMatch{ GivenCommitID: opts.LastCommitID, - CurrentCommitID: opts.LastCommitID, + CurrentCommitID: commit.ID.String(), } } // The file wasn't modified, so we are good to delete it @@ -466,3 +510,403 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up } return file, nil } + +func CreateOrUpdateOrDeleteRepofiles(repo *models.Repository, doer *models.User, opts *BatchUpdateFileOptions, exchange *ExchangeFileOption) (*structs.BatchFileResponse, error) { + + var protectedPatterns []glob.Glob + if opts.OldBranch == "" { + opts.OldBranch = repo.DefaultBranch + } + + if opts.NewBranch == "" { + opts.NewBranch = opts.OldBranch + } + + // oldBranch must exist for this operation + if _, err := repo_module.GetBranch(repo, opts.OldBranch); err != nil { + return nil, err + } + + if opts.NewBranch != opts.OldBranch { + existingBranch, err := repo_module.GetBranch(repo, opts.NewBranch) + if existingBranch != nil { + return nil, models.ErrBranchAlreadyExists{ + BranchName: opts.NewBranch, + } + } + if err != nil && !git.IsErrBranchNotExist(err) { + return nil, err + } + } else { + protectedBranch, err := repo.GetBranchProtection(opts.OldBranch) + if err != nil { + return nil, err + } + if protectedBranch != nil { + if !protectedBranch.CanUserPush(doer.ID) { + return nil, models.ErrUserCannotCommit{ + UserName: doer.LowerName, + } + } + if protectedBranch.RequireSignedCommits { + _, _, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch) + if err != nil { + if !models.IsErrWontSign(err) { + return nil, err + } + return nil, models.ErrUserCannotCommit{ + UserName: doer.LowerName, + } + } + } + protectedPatterns = protectedBranch.GetProtectedFilePatterns() + } + } + + message := strings.TrimSpace(opts.Message) + author, commiter := GetAuthorAndCommitterUsers(opts.Author, opts.Commiter, doer) + + t, err := NewTemporaryUploadRepository(repo) + if err != nil { + log.Error("%v", err) + } + defer t.Close() + if err := t.Clone(opts.OldBranch); err != nil { + return nil, err + } + if err := t.SetDefaultIndex(); err != nil { + return nil, err + } + + // Get the commit of the original branch + commit, err := t.GetBranchCommit(opts.OldBranch) + if err != nil { + return nil, err + } + + if opts.LastCommitID == "" { + opts.LastCommitID = commit.ID.String() + } else { + lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + if err != nil { + return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %v", err) + } + opts.LastCommitID = lastCommitID.String() + } + var commitHash string + var treeNames []string + + for { + select { + case file := <-exchange.FileChan: + for _, pat := range protectedPatterns { + if pat.Match(strings.ToLower(file.TreePath)) { + return nil, models.ErrFilePathProtected{ + Path: file.TreePath, + } + } + } + + optTreePath := file.TreePath + optFromTreePath := file.FromTreePath + optActionType := file.ActionType + optContent := file.Content + + if optTreePath != "" && optFromTreePath == "" { + optFromTreePath = optTreePath + } + + treePath := CleanUploadFileName(optTreePath) + if treePath == "" { + return nil, models.ErrFilenameInvalid{ + Path: optTreePath, + } + } + + fromTreePath := CleanUploadFileName(optFromTreePath) + if fromTreePath == "" && optFromTreePath != "" { + return nil, models.ErrFilenameInvalid{ + Path: optFromTreePath, + } + } + + if optActionType == ActionTypeDelete { + // Get the files in the index + filesInIndex, err := t.LsFiles(optTreePath) + if err != nil { + return nil, fmt.Errorf("DeleteRepoFile: %v", err) + } + // Find the file we want to delete in the index + inFilelist := false + for _, file := range filesInIndex { + if file == optTreePath { + inFilelist = true + break + } + } + if !inFilelist { + return nil, models.ErrRepoFileDoesNotExist{ + Path: optTreePath, + } + } + + // Get the entry of treePath and check if the SHA given is the same as the file + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return nil, err + } + if opts.SHA != "" { + // If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error + if opts.SHA != entry.ID.String() { + return nil, models.ErrSHADoesNotMatch{ + Path: treePath, + GivenSHA: opts.SHA, + CurrentSHA: entry.ID.String(), + } + } + } else if opts.LastCommitID != "" { + // If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw + // an error, but only if we aren't creating a new branch. + if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { + // CommitIDs don't match, but we don't want to throw a ErrCommitIDDoesNotMatch unless + // this specific file has been edited since opts.LastCommitID + if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil { + return nil, err + } else if changed { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + // The file wasn't modified, so we are good to delete it + } + } else { + // When deleting a file, a lastCommitID or SHA needs to be given to make sure other commits haven't been + // made. We throw an error if one wasn't provided. + return nil, models.ErrSHAOrCommitIDNotProvided{} + } + + // Remove the file from the index + if err := t.RemoveFilesFromIndex(optTreePath); err != nil { + return nil, err + } + + } else { + encoding := "UTF-8" + bom := false + executable := false + + if optActionType == ActionTypeUpdate { + fromEntry, err := commit.GetTreeEntryByPath(fromTreePath) + if err != nil { + return nil, err + } + if opts.SHA != "" { + if opts.SHA != fromEntry.ID.String() { + return nil, models.ErrSHADoesNotMatch{ + Path: optTreePath, + GivenSHA: opts.SHA, + CurrentSHA: fromEntry.ID.String(), + } + } + } else if opts.LastCommitID != "" { + if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { + if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil { + return nil, err + } else if changed { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + } + } else { + return nil, models.ErrSHAOrCommitIDNotProvided{} + } + encoding, bom = detectEncodingAndBOM(fromEntry, repo) + executable = fromEntry.IsExecutable() + } + + treePathParts := strings.Split(treePath, "/") + subTreePath := "" + for index, part := range treePathParts { + subTreePath = path.Join(subTreePath, part) + entry, err := commit.GetTreeEntryByPath(subTreePath) + if err != nil { + if git.IsErrNotExist(err) { + break + } + return nil, err + } + if index < len(treePathParts)-1 { + if !entry.IsDir() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeBlob, + } + } + } else if entry.IsLink() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeSymlink, + } + } else if entry.IsDir() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeTree, + } + } else if fromTreePath != treePath || optActionType == ActionTypeCreate { + return nil, models.ErrRepoFileAlreadyExists{ + Path: treePath, + } + } + } + // Get the two paths (might be the same if not moving) from the index if they exist + filesInIndex, err := t.LsFiles(optTreePath, optFromTreePath) + if err != nil { + return nil, fmt.Errorf("UpdateRepoFile: %v", err) + } + + if optActionType == ActionTypeCreate { + for _, file := range filesInIndex { + if file == optTreePath { + return nil, models.ErrRepoFileAlreadyExists{ + Path: optTreePath, + } + } + } + } + + // Remove the old path from the tree + if fromTreePath != treePath && len(filesInIndex) > 0 { + for _, file := range filesInIndex { + if file == fromTreePath { + if err := t.RemoveFilesFromIndex(optFromTreePath); err != nil { + return nil, err + } + } + } + } + + content := optContent + if bom { + content = string(charset.UTF8BOM) + content + } + if encoding != "UTF-8" { + charsetEncoding, _ := stdcharset.Lookup(encoding) + if charsetEncoding != nil { + result, _, err := transform.String(charsetEncoding.NewEncoder(), content) + if err != nil { + log.Error("Error re-encoding %s (%s) as %s - will stay as UTF-8: %v", optTreePath, optFromTreePath, encoding, err) + result = content + } + content = result + } else { + log.Error("Unknown encoding: %s", encoding) + } + } + + optContent = content + var lfsMetaObject *models.LFSMetaObject + if setting.LFS.StartServer { + filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{ + Attributes: []string{"filter"}, + Filenames: []string{treePath}, + }) + if err != nil { + return nil, err + } + + if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" { + pointer, err := lfs.GeneratePointer(strings.NewReader(optContent)) + if err != nil { + return nil, err + } + lfsMetaObject = &models.LFSMetaObject{Pointer: pointer, RepositoryID: repo.ID} + content = pointer.StringContent() + } + } + + objectHash, err := t.HashObject(strings.NewReader(content)) + if err != nil { + return nil, err + } + + if executable { + if err := t.AddObjectToIndex("100755", objectHash, treePath); err != nil { + return nil, err + } + } else { + if err := t.AddObjectToIndex("100644", objectHash, treePath); err != nil { + return nil, err + } + } + + if lfsMetaObject != nil { + lfsMetaObject, err = models.NewLFSMetaObject(lfsMetaObject) + if err != nil { + return nil, err + } + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(lfsMetaObject.Pointer) + if err != nil { + return nil, err + } + if !exist { + if err := contentStore.Put(lfsMetaObject.Pointer, strings.NewReader(optContent)); err != nil { + if _, err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil { + return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %v)", lfsMetaObject.Oid, err2, err) + } + return nil, err + } + } + } + } + opts.Files = append(opts.Files, file) + treeNames = append(treeNames, file.TreePath) + case err := <-exchange.ErrChan: + return nil, err + case _ = <-exchange.StopChan: + goto end + } + } +end: + // Now write the tree + treeHash, err := t.WriteTree() + if err != nil { + return nil, err + } + + // Now commit the tree + if opts.Dates != nil { + commitHash, err = t.CommitTreeWithDate(author, commiter, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer) + } else { + commitHash, err = t.CommitTree(author, commiter, treeHash, message, opts.Signoff) + } + if err != nil { + return nil, err + } + + if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + log.Error("%T %v", err, err) + return nil, err + } + + commit, err = t.GetCommit(commitHash) + if err != nil { + return nil, err + } + + file, err := GetBatchFileResponseFromCommit(repo, commit, opts.NewBranch, treeNames) + if err != nil { + return nil, err + } + + return file, nil +} diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 3b40224e9..f6b7e6dcb 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -30,6 +30,19 @@ type CreateFileOptions struct { Content string `json:"content"` } +// BatchCreateFileOptions options for creating more files +type BatchChangeFileOptions struct { + Header FileOptions `json:"header"` + Files []struct { + // enum: text,base64 + Encoding string `json:"encoding"` + FilePath string `json:"file_path"` + Content string `json:"content"` + // enum: create,update,delete + ActionType string `json:"action_type"` + } `json:"files"` +} + // DeleteFileOptions options for deleting files (used for other File structs below) // 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 { @@ -106,6 +119,12 @@ type FileResponse struct { Verification *PayloadCommitVerification `json:"verification"` } +type BatchFileResponse struct { + Contents []*ContentsResponse `json:"contents"` + 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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9da5c6618..3094d3e5a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1032,6 +1032,9 @@ func Routes() *web.Route { m.Put("", bind(api.UpdateFileOptions{}), repo.UpdateFile) m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile) }, reqRepoWriter(models.UnitTypeCode), reqToken()) + m.Group("/batch", func() { + m.Post("", bind(api.BatchChangeFileOptions{}), repo.BatchChangeFile) + }, reqRepoWriter(models.UnitTypeCode), reqToken()) }, reqRepoReader(models.UnitTypeCode)) m.Get("/signing-key.gpg", misc.SigningKey) m.Group("/topics", func() { @@ -1045,6 +1048,7 @@ func Routes() *web.Route { m.Get("/issue_templates", context.ReferencesGitRepo(false), repo.GetIssueTemplates) m.Get("/languages", reqRepoReader(models.UnitTypeCode), repo.GetLanguages) m.Get("/diffs", context.RepoRef(), repo.GetRepoDiffs) + m.Get("/blame", context.RepoRef(), repo.GetRepoRefBlame) }, repoAssignment()) }) diff --git a/routers/api/v1/repo/blame.go b/routers/api/v1/repo/blame.go new file mode 100644 index 000000000..44159902d --- /dev/null +++ b/routers/api/v1/repo/blame.go @@ -0,0 +1,115 @@ +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" +) + +type APIBlameResponse struct { + FileSize int64 `json:"file_size"` + FileName string `json:"file_name"` + NumberLines int `json:"num_lines"` + BlameParts []git.ApiBlamePart `json:"blame_parts"` +} + +func GetRepoRefBlame(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/blame repository repoGetRefBlame + // --- + // summary: Get blame from a repository by sha and filepath*** + // 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: sha + // in: query + // description: repo commit sha or branch + // type: string + // required: true + // - name: filepath + // in: query + // description: filepath in repository + // type: string + // required: true + // responses: + // 200: + // description: success + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound() + return + } + + var commit *git.Commit + if sha := ctx.QueryTrim("sha"); len(sha) > 0 { + var err error + commit, err = ctx.Repo.GitRepo.GetCommit(sha) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + } + return + } + } + fmt.Println(commit) + + filepath := ctx.QueryTrim("filepath") + fmt.Println(filepath) + entry, err := commit.GetTreeEntryByPath(filepath) + if err != nil { + ctx.NotFoundOrServerError("commit.GetTreeEntryByPath", git.IsErrNotExist, err) + return + } + + blob := entry.Blob() + numLines, err := blob.GetBlobLineCount() + if err != nil { + ctx.NotFound("GetBlobLineCount", err) + return + } + blameReader, err := git.CreateBlameReader(ctx, models.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name), commit.ID.String(), filepath) + if err != nil { + ctx.NotFound("CreateBlameReader", err) + return + } + defer blameReader.Close() + + blameParts := make([]git.ApiBlamePart, 0) + currentNumber := 1 + for { + blamePart, err := blameReader.NextApiPart(ctx.Repo.GitRepo) + if err != nil { + ctx.NotFound("NextPart", err) + return + } + if blamePart == nil { + break + } + blamePart.CurrentNumber = currentNumber + blameParts = append(blameParts, *blamePart) + currentNumber += blamePart.EffectLine + } + + ctx.JSON(http.StatusOK, APIBlameResponse{ + FileSize: blob.Size(), + FileName: blob.Name(), + NumberLines: numLines, + BlameParts: blameParts, + }) +} diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go index 282798c54..edfbbc321 100644 --- a/routers/api/v1/repo/commits.go +++ b/routers/api/v1/repo/commits.go @@ -539,7 +539,34 @@ func GetFileAllCommits(ctx *context.APIContext) { } // 获取 commit diff +// Diff get diffs by commit on a repository func Diff(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits/{sha}/diff repository repoGetDiffs*** + // --- + // summary: Get diffs by commit from a repository + // 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: sha + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // 200: + // description: success + // "404": + // "$ref": "#/responses/notFound" commitID := ctx.Params(":sha") diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 3cb41457d..301ab5a0a 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -280,11 +280,76 @@ func CreateFile(ctx *context.APIContext) { if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil { handleCreateOrUpdateFileError(ctx, err) + return } else { ctx.JSON(http.StatusCreated, fileResponse) } } +// BatchChangeFile handles API call for change some files*** +func BatchChangeFile(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/contents/batch repository repoBatchChangeFile + // --- + // summary: Change some 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/BatchChangeFileOptions" + // responses: + // "201": + // "$ref": "#/responses/BatchFileResponse" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/error" + apiBatchOpts := web.GetForm(ctx).(*api.BatchChangeFileOptions) + fmt.Println(apiBatchOpts) + + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) + } + + if apiBatchOpts.Header.BranchName == "" { + apiBatchOpts.Header.BranchName = ctx.Repo.Repository.DefaultBranch + } + + if apiBatchOpts.Header.Message == "" { + apiBatchOpts.Header.Message = time.Now().Format("RFC3339") + } + + if apiBatchOpts.Header.Dates.Author.IsZero() { + apiBatchOpts.Header.Dates.Author = time.Now() + } + + if apiBatchOpts.Header.Dates.Committer.IsZero() { + apiBatchOpts.Header.Dates.Committer = time.Now() + } + + if batchFileResponse, err := createOrUpdateOrDeleteFiles(ctx, apiBatchOpts); err != nil { + handleCreateOrUpdateFileError(ctx, err) + } else { + ctx.JSON(http.StatusOK, batchFileResponse) + } +} + // UpdateFile handles API call for updating a file func UpdateFile(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile @@ -392,6 +457,59 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) { ctx.Error(http.StatusInternalServerError, "UpdateFile", err) } +func createOrUpdateOrDeleteFiles(ctx *context.APIContext, apiBatchOpts *api.BatchChangeFileOptions) (*api.BatchFileResponse, error) { + if !canWriteFiles(ctx.Repo) { + return nil, models.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.User.ID, + RepoName: ctx.Repo.Repository.LowerName, + } + } + fileChan := make(chan repofiles.BatchSingleFileOption) + stopChan := make(chan bool) + errChan := make(chan error) + exchangeOption := &repofiles.ExchangeFileOption{ + FileChan: fileChan, + StopChan: stopChan, + ErrChan: errChan, + } + go func() { + for _, f := range apiBatchOpts.Files { + if f.Encoding == "base64" { + content, err := base64.StdEncoding.DecodeString(f.Content) + exchangeOption.ErrChan <- err + f.Content = string(content) + } + exchangeOption.FileChan <- repofiles.BatchSingleFileOption{ + Content: f.Content, + TreePath: f.FilePath, + ActionType: repofiles.ToFileActionType(f.ActionType), + } + } + exchangeOption.StopChan <- true + }() + + opts := &repofiles.BatchUpdateFileOptions{ + Message: apiBatchOpts.Header.Message, + OldBranch: apiBatchOpts.Header.BranchName, + NewBranch: apiBatchOpts.Header.NewBranchName, + Commiter: &repofiles.IdentityOptions{ + Name: apiBatchOpts.Header.Committer.Name, + Email: apiBatchOpts.Header.Committer.Email, + }, + Author: &repofiles.IdentityOptions{ + Name: apiBatchOpts.Header.Author.Name, + Email: apiBatchOpts.Header.Author.Email, + }, + Dates: &repofiles.CommitDateOptions{ + Author: apiBatchOpts.Header.Dates.Author, + Committer: apiBatchOpts.Header.Dates.Committer, + }, + Signoff: apiBatchOpts.Header.Signoff, + } + + return repofiles.CreateOrUpdateOrDeleteRepofiles(ctx.Repo.Repository, ctx.User, opts, exchangeOption) +} + // Called from both CreateFile or UpdateFile to handle both func createOrUpdateFile(ctx *context.APIContext, opts *repofiles.UpdateRepoFileOptions) (*api.FileResponse, error) { if !canWriteFiles(ctx.Repo) { diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index a86b610f1..bc052f946 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -119,6 +119,9 @@ type swaggerParameterBodies struct { // in:body CreateFileOptions api.CreateFileOptions + // in:body + BatchChangeFileOptions api.BatchChangeFileOptions + // in:body UpdateFileOptions api.UpdateFileOptions diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 2755891bd..c4058a0a1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2424,6 +2424,56 @@ } } }, + "/repos/{owner}/{repo}/blame": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get blame from a repository by sha and filepath***", + "operationId": "repoGetRefBlame", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "repo commit sha or branch", + "name": "sha", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "filepath in repository", + "name": "filepath", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "success" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/branch_name_set": { "get": { "produces": [ @@ -3419,6 +3469,59 @@ } } }, + "/repos/{owner}/{repo}/contents/batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Change some files in a repository***", + "operationId": "repoBatchChangeFile", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/BatchChangeFileOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/BatchFileResponse" + }, + "403": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/error" + } + } + } + }, "/repos/{owner}/{repo}/contents/{filepath}": { "get": { "produces": [ @@ -13414,6 +13517,50 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "BatchChangeFileOptions": { + "description": "BatchCreateFileOptions options for creating more files", + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action_type": { + "type": "string", + "enum": [ + "create", + "update", + "delete" + ], + "x-go-name": "ActionType" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "encoding": { + "type": "string", + "enum": [ + "text", + "base64" + ], + "x-go-name": "Encoding" + }, + "file_path": { + "type": "string", + "x-go-name": "FilePath" + } + } + }, + "x-go-name": "Files" + }, + "header": { + "$ref": "#/definitions/FileOptions" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Branch": { "description": "Branch represents a repository branch", "type": "object", @@ -16064,6 +16211,42 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "FileOptions": { + "description": "FileOptions options for all file APIs", + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/Identity" + }, + "branch": { + "description": "branch (optional) to base this file from. if not given, the default branch is used", + "type": "string", + "x-go-name": "BranchName" + }, + "committer": { + "$ref": "#/definitions/Identity" + }, + "dates": { + "$ref": "#/definitions/CommitDateOptions" + }, + "message": { + "description": "message (optional) for the commit of this file. 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 `branch` before creating the file", + "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" + }, "FileResponse": { "description": "FileResponse contains information about a repo's file", "type": "object",