diff --git a/models/git/commit_comment.go b/models/git/commit_comment.go new file mode 100644 index 0000000000000..5a73879403c7c --- /dev/null +++ b/models/git/commit_comment.go @@ -0,0 +1,546 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "slices" + "strconv" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/renderhelper" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/google/uuid" +) + +type ( + CommitCommentList []*CommitComment + ReactionMap map[string][]string + AttachmentMap map[string]*AttachmentOptions +) + +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + Line int64 + FileName string + CommitSHA string `xorm:"VARCHAR(64)"` + Attachments string `xorm:"JSON TEXT"` + Reactions string `xorm:"JSON TEXT"` + Comment string `xorm:"LONGTEXT"` + RenderedComment template.HTML `xorm:"-"` + ContentVersion int `xorm:"NOT NULL DEFAULT 0"` + RefRepoID int64 `xorm:"index"` + Repo *repo_model.Repository `xorm:"-"` + ReplyToCommentID int64 `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +type CreateCommitCommentOptions struct { + Doer *user_model.User + Repo *repo_model.Repository + + CommitID int64 + CommitSHA string + LineNum int64 + Reactions string + Comment string + FileName string + Attachments AttachmentMap + RefRepoID int64 + ReplyToCommentID int64 +} + +type AttachmentOptions struct { + FileName string + Size int64 + UploaderID int64 +} + +type FindCommitCommentOptions struct { + db.ListOptions + RepoID int64 + CommitSHA string + Since int64 + Before int64 + Line int64 + FileName string +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// HashTag returns unique hash tag for CommitComment. +func (commitComment *CommitComment) HashTag() string { + return fmt.Sprintf("commitComment-%d", commitComment.ID) +} + +func (commitComment *CommitComment) LoadRepo(ctx context.Context) (err error) { + if commitComment.Repo == nil && commitComment.RefRepoID != 0 { + commitComment.Repo, err = repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %w", commitComment.RefRepoID, err) + } + } + return nil +} + +// LoadPoster loads poster +func (commitComment *CommitComment) LoadPoster(ctx context.Context) (err error) { + if commitComment.Poster == nil && commitComment.PosterID != 0 { + commitComment.Poster, err = user_model.GetPossibleUserByID(ctx, commitComment.PosterID) + if err != nil { + commitComment.PosterID = user_model.GhostUserID + commitComment.Poster = user_model.NewGhostUser() + if !user_model.IsErrUserNotExist(err) { + return fmt.Errorf("getUserByID.(poster) [%d]: %w", commitComment.PosterID, err) + } + return nil + } + } + return err +} + +func (commitComment *CommitComment) UnsignedLine() uint64 { + if commitComment.Line < 0 { + return uint64(commitComment.Line * -1) + } + return uint64(commitComment.Line) +} + +func (commitComment *CommitComment) TreePath() string { + return commitComment.FileName +} + +// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. +func (commitComment *CommitComment) DiffSide() string { + if commitComment.Line < 0 { + return "previous" + } + return "proposed" +} + +func (commitComment *CommitComment) GroupReactionsByType() (ReactionMap, error) { + reactions := make(ReactionMap) + + err := json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return nil, errors.New("GroupReactionsByType") + } + return reactions, nil +} + +func (commitComment *CommitComment) GroupAttachmentsByUUID() (AttachmentMap, error) { + attachmentMap := make(AttachmentMap) + err := json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + return nil, err + } + return attachmentMap, nil +} + +// HasUser check if user has reacted +func (commitComment *CommitComment) HasUser(reaction string, userID int64) bool { + if userID == 0 { + return false + } + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return false + } + list := reactions[reaction] + hasUser := false + for _, userid := range list { + id, _ := strconv.ParseInt(userid, 10, 64) + if id == userID { + hasUser = true + return hasUser + } + } + return hasUser +} + +// GetFirstUsers returns first reacted user display names separated by comma +func (commitComment *CommitComment) GetFirstUsers(ctx context.Context, reaction string) string { + var buffer bytes.Buffer + rem := setting.UI.ReactionMaxUserNum + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return "" + } + list := reactions[reaction] + for _, userid := range list { + if buffer.Len() > 0 { + buffer.WriteString(", ") + } + id, _ := strconv.ParseInt(userid, 10, 64) + user, _ := user_model.GetUserByID(ctx, id) + + buffer.WriteString(user.Name) + if rem--; rem == 0 { + break + } + } + return buffer.String() +} + +// GetMoreUserCount returns count of not shown users in reaction tooltip +func (commitComment *CommitComment) GetMoreUserCount(reaction string) int { + if reaction == "" { + return 0 + } + reactions, err := commitComment.GroupReactionsByType() + if err != nil { + return 0 + } + list := reactions[reaction] + + if len(list) <= setting.UI.ReactionMaxUserNum { + return 0 + } + return len(list) - setting.UI.ReactionMaxUserNum +} + +func GetCommitCommentByID(ctx context.Context, repoID, ID int64) (*CommitComment, error) { + commitComment := &CommitComment{ + RefRepoID: repoID, + ID: ID, + } + has, err := db.GetEngine(ctx).Get(commitComment) + if err != nil { + return nil, err + } else if !has { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + return commitComment, err +} + +func GetCommitCommentBySHA(ctx context.Context, repoID int64, commitSHA string) (*CommitComment, error) { + commitComment := &CommitComment{ + RefRepoID: repoID, + CommitSHA: commitSHA, + } + has, err := db.GetEngine(ctx).Get(commitComment) + if err != nil { + return nil, err + } else if !has { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + return commitComment, err +} + +// CreateCommitComment creates comment with context +func CreateCommitComment(ctx context.Context, opts *CreateCommitCommentOptions) (_ *CommitComment, err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + e := db.GetEngine(ctx) + reactions := make(ReactionMap) + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return nil, err + } + + jsonString := string(jsonBytes) + + attachmentsJSONBytes, err := json.Marshal(opts.Attachments) + if err != nil { + return nil, err + } + + attachmentsJSON := string(attachmentsJSONBytes) + + commit := &CommitComment{ + PosterID: opts.Doer.ID, + Poster: opts.Doer, + CommitSHA: opts.CommitSHA, + FileName: opts.FileName, + Line: opts.LineNum, + Comment: opts.Comment, + Reactions: jsonString, + Attachments: attachmentsJSON, + RefRepoID: opts.RefRepoID, + ReplyToCommentID: opts.ReplyToCommentID, + } + if _, err = e.Insert(commit); err != nil { + return nil, err + } + + if err = committer.Commit(); err != nil { + return nil, err + } + return commit, nil +} + +func UpdateCommitComment(ctx context.Context, attachmentMap *AttachmentMap, commitComment *CommitComment) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + attachmentsJSONBytes, err := json.Marshal(attachmentMap) + if err != nil { + return err + } + + attachmentsJSON := string(attachmentsJSONBytes) + + commit := &CommitComment{ + PosterID: commitComment.PosterID, + Poster: commitComment.Poster, + CommitSHA: commitComment.CommitSHA, + FileName: commitComment.FileName, + Line: commitComment.Line, + Comment: commitComment.Comment, + ContentVersion: commitComment.ContentVersion, + Attachments: attachmentsJSON, + RefRepoID: commitComment.RefRepoID, + } + + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commit) + if err != nil { + return err + } + err = committer.Commit() + return err +} + +// DeleteComment deletes the comment +func DeleteCommitComment(ctx context.Context, commitComment *CommitComment) error { + e := db.GetEngine(ctx) + if _, err := e.ID(commitComment.ID).NoAutoCondition().Delete(commitComment); err != nil { + return err + } + return nil +} + +func FindCommitCommentsByCommit(ctx context.Context, opts *FindCommitCommentOptions, commitComment *CommitComment) (CommitCommentList, error) { + var commitCommentList CommitCommentList + sess := db.GetEngine(ctx).Where(opts.ToConds()) + + if opts.CommitSHA == "" { + return nil, nil + } + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, opts) + } + + err := sess.Table(&CommitComment{}).Where(opts.ToConds()).Find(&commitCommentList) + if err != nil { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + for _, cd := range commitCommentList { + var err error + rctx := renderhelper.NewRenderContextRepoComment(ctx, commitComment.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + + if cd.RenderedComment, err = markdown.RenderString(rctx, cd.Comment); err != nil { + return nil, err + } + cd.Repo = commitComment.Repo + cd.Poster = commitComment.Poster + } + return commitCommentList, nil +} + +func FindCommitCommentsByLine(ctx context.Context, opts *FindCommitCommentOptions, commitComment *CommitComment) (CommitCommentList, error) { + var commitCommentList CommitCommentList + + sess := db.GetEngine(ctx) + + err := sess.Table(&CommitComment{}).Where("commit_sha=? AND line=? ", opts.CommitSHA, opts.Line).Find(&commitCommentList) + if err != nil { + return nil, err + } + err = commitComment.LoadRepo(ctx) + if err != nil { + return nil, err + } + err = commitComment.LoadPoster(ctx) + if err != nil { + return nil, err + } + for _, cd := range commitCommentList { + var err error + rctx := renderhelper.NewRenderContextRepoComment(ctx, commitComment.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + + if cd.RenderedComment, err = markdown.RenderString(rctx, cd.Comment); err != nil { + return nil, err + } + cd.Repo = commitComment.Repo + cd.Poster = commitComment.Poster + } + return commitCommentList, nil +} + +func CreateCommitCommentReaction(ctx context.Context, reaction string, userID int64, commitComment *CommitComment) error { + if !setting.UI.ReactionsLookup.Contains(reaction) { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + reactions := make(ReactionMap) + + err = json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return err + } + + reactions[reaction] = append(reactions[reaction], strconv.FormatInt(userID, 10)) + + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return err + } + + jsonString := string(jsonBytes) + + commitComment.Reactions = jsonString + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + + return nil +} + +func DeleteCommentReaction(ctx context.Context, reaction string, userID int64, commitComment *CommitComment) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + reactions := make(ReactionMap) + + err = json.Unmarshal([]byte(commitComment.Reactions), &reactions) + if err != nil { + return err + } + + list := reactions[reaction] + userid := strconv.FormatInt(userID, 10) + idx := slices.Index(list, userid) + reactions[reaction] = slices.Delete(list, idx, idx+1) + + for reactionType, userIDs := range reactions { + if len(userIDs) == 0 { + delete(reactions, reactionType) // Delete the key with an empty slice + } + } + + jsonBytes, err := json.Marshal(reactions) + if err != nil { + return err + } + + jsonString := string(jsonBytes) + + commitComment.Reactions = jsonString + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + + return nil +} + +func SaveTemporaryAttachment(ctx context.Context, file io.Reader, opts *AttachmentOptions) (string, error) { + attachmentUUID := uuid.New().String() + _, err := storage.Attachments.Save(attachmentUUID, file, opts.Size) + return attachmentUUID, err +} + +func UploadCommitAttachment(ctx context.Context, file io.Reader, commitComment *CommitComment, opts *AttachmentOptions) error { + attachmentUUID := uuid.New().String() + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + attachment := make(AttachmentMap) + attachment[attachmentUUID] = opts + + jsonBytes, err := json.Marshal(attachment) + if err != nil { + return err + } + jsonString := string(jsonBytes) + commitComment.Attachments = jsonString + + sess := db.GetEngine(ctx) + _, err = sess.ID(commitComment.ID).Where("commit_sha = ?", commitComment.CommitSHA).Update(commitComment) + if err != nil { + return err + } + + err = committer.Commit() + if err != nil { + return err + } + return err +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 0c60abcecd07f..6aad78b44d4f7 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -8,14 +8,17 @@ import ( "errors" "fmt" "html/template" + "maps" "net/http" "path" + "strconv" "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" @@ -25,24 +28,35 @@ import ( "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/forms" git_service "code.gitea.io/gitea/services/git" "code.gitea.io/gitea/services/gitdiff" repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/services/repository/gitgraph" + user_service "code.gitea.io/gitea/services/user" ) const ( - tplCommits templates.TplName = "repo/commits" - tplGraph templates.TplName = "repo/graph" - tplGraphDiv templates.TplName = "repo/graph/div" - tplCommitPage templates.TplName = "repo/commit_page" + tplCommits templates.TplName = "repo/commits" + tplGraph templates.TplName = "repo/graph" + tplGraphDiv templates.TplName = "repo/graph/div" + tplCommitPage templates.TplName = "repo/commit_page" + tplCommitConversation templates.TplName = "repo/diff/commit_conversation" + tplCommitCommentReactions templates.TplName = "repo/issue/view_content/commit_reactions" + tplCommitAttachment templates.TplName = "repo/issue/view_content/commit_attachments" ) // RefCommits render commits page @@ -345,6 +359,7 @@ func Diff(ctx *context.Context) { ctx.Data["AfterCommitID"] = commitID ctx.Data["Username"] = userName ctx.Data["Reponame"] = repoName + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled var parentCommit *git.Commit var parentCommitID string @@ -417,6 +432,24 @@ func Diff(ctx *context.Context) { ctx.Data["MergedPRIssueNumber"] = pr.Index } + commitComment, err := git_model.GetCommitCommentBySHA(ctx, ctx.Repo.Repository.ID, commitID) + if err != nil { + ctx.ServerError("GetCommitCommentBySHA", err) + return + } + + if commitComment != nil { + err := git_service.LoadCommitComments(ctx, diff, commitComment, ctx.Doer) + if err != nil { + ctx.ServerError("LoadCommitComments", err) + return + } + } + upload.AddUploadContext(ctx, "comment") + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + ctx.HTML(http.StatusOK, tplCommitPage) } @@ -469,3 +502,455 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m } return commits, nil } + +func RenderCommitCommentForm(ctx *context.Context) { + CommitSHA := ctx.PathParam("sha") + + ctx.Data["PageIsDiff"] = true + ctx.Data["CommitSHA"] = CommitSHA + ctx.Data["AfterCommitID"] = CommitSHA + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.HTML(http.StatusOK, tplNewComment) +} + +func CreateCommitComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CodeCommentForm) + if ctx.Written() { + return + } + + signedLine := form.Line + if form.Side == "previous" { + signedLine *= -1 + } + replyid := form.Reply + attachmentsMap := make(git_model.AttachmentMap) + + if ctx.Session.Get("attachmentsMaps") != nil { + attachmentsMap = ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + } + + opts := git_model.CreateCommitCommentOptions{ + RefRepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, + Doer: ctx.Doer, + Comment: form.Content, + FileName: form.TreePath, + LineNum: signedLine, + ReplyToCommentID: replyid, + CommitSHA: form.LatestCommitID, + Attachments: attachmentsMap, + } + commitComment, err := git_service.CreateCommitComment(ctx, + ctx.Doer, + ctx.Repo.GitRepo, + opts, + ) + if err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + if ctx.Session.Get("attachmentsMaps") != nil { + err = ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + } + renderCommitComment(ctx, commitComment, form.Origin, signedLine) +} + +func renderCommitComment(ctx *context.Context, commitComment *git_model.CommitComment, origin string, signedLine int64) { + ctx.Data["PageIsDiff"] = true + + opts := git_model.FindCommitCommentOptions{ + CommitSHA: commitComment.CommitSHA, + Line: signedLine, + } + comments, err := git_model.FindCommitCommentsByLine(ctx, &opts, commitComment) + if err != nil { + ctx.ServerError("FetchCodeCommentsByLine", err) + return + } + + if len(comments) == 0 { + ctx.HTML(http.StatusOK, tplConversationOutdated) + return + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + + upload.AddUploadContext(ctx, "comment") + ctx.Data["comments"] = comments + ctx.Data["AfterCommitID"] = commitComment.CommitSHA + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + switch origin { + case "diff": + ctx.HTML(http.StatusOK, tplCommitConversation) + case "timeline": + ctx.HTML(http.StatusOK, tplTimelineConversation) + default: + ctx.HTTPError(http.StatusBadRequest, "Unknown origin: "+origin) + } +} + +// DeleteCommitComment delete comment of commit +func DeleteCommitComment(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + ctx.HTTPError(http.StatusForbidden) + return + } + + if err = git_model.DeleteCommitComment(ctx, commitComment); err != nil { + ctx.ServerError("DeleteCommitComment", err) + return + } + + ctx.Status(http.StatusOK) +} + +func UpdateCommitComment(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + ctx.ServerError("GetCommitCommentByID", err) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + ctx.HTTPError(http.StatusForbidden) + return + } + + newContent := ctx.FormString("content") + + if newContent != commitComment.Comment { + commitComment.Comment = newContent + commitComment.ContentVersion++ + } + files := ctx.FormStrings("files[]") + filesMap := make(map[string]struct{}) + for _, key := range files { + filesMap[key] = struct{}{} + } + + uploadedAttachments := make(git_model.AttachmentMap) + if ctx.Session.Get("attachmentsMaps") != nil { + uploadedAttachments = ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + + // handle removed files + for key := range attachmentMap { + if _, exists := filesMap[key]; !exists { + delete(attachmentMap, key) + } + } + maps.Copy(attachmentMap, uploadedAttachments) + err = git_model.UpdateCommitComment(ctx, &attachmentMap, commitComment) + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + + var renderedContent template.HTML + if commitComment.Comment != "" { + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(commitComment.ID, 10), + }) + renderedContent, err = markdown.RenderString(rctx, commitComment.Comment) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } else { + contentEmpty := fmt.Sprintf(`%s`, ctx.Tr("repo.issues.no_content")) + renderedContent = template.HTML(contentEmpty) + } + + attachHTML, err := ctx.RenderToHTML(tplCommitAttachment, map[string]any{ + "Attachments": attachmentMap, + "CommitComment": commitComment, + }) + if err != nil { + ctx.ServerError("attachmentsHTML.HTMLString", err) + return + } + + if ctx.Session.Get("attachmentsMaps") != nil { + err = ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + } + upload.AddUploadContext(ctx, "comment") + ctx.JSON(http.StatusOK, map[string]any{ + "content": renderedContent, + "contentVersion": commitComment.ContentVersion, + "attachments": attachHTML, + }) +} + +func CancelCommitComment(ctx *context.Context) { + if ctx.Session.Get("attachmentsMaps") != nil { + err := ctx.Session.Delete("attachmentsMaps") + if err != nil { + ctx.ServerError("CancelCommitComment", err) + return + } + } +} + +func ChangeCommitCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + ctx.ServerError("GetCommitCommentByID", err) + return + } + + action := ctx.PathParam("action") + + if !ctx.IsSigned || (ctx.Doer.ID != commitComment.PosterID) { + if log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + commitComment.PosterID, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.HTTPError(http.StatusForbidden) + return + } + + switch action { + case "react": + err := git_service.CreateCommentReaction(ctx, ctx.Doer, commitComment, form.Content) + if err != nil { + log.Info("CreateCommentReaction: %s", err) + break + } + + case "unreact": + if err := git_service.DeleteCommentReaction(ctx, ctx.Doer, commitComment, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + default: + ctx.NotFound(nil) + return + } + + if len(commitComment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + reactions, _ := commitComment.GroupReactionsByType() + html, err := ctx.RenderToHTML(tplCommitCommentReactions, map[string]any{ + "ActionURL": fmt.Sprintf("%s/commit/%s/comments/%d/reactions", ctx.Repo.RepoLink, commitComment.CommitSHA, commitComment.ID), + "Reactions": reactions, + "CommitComment": commitComment, + }) + if err != nil { + ctx.ServerError("ChangeCommentReaction.HTMLString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} + +func UploadCommitAttachment(ctx *context.Context) { + if !ctx.IsSigned { + ctx.HTTPError(http.StatusForbidden) + return + } + + if !setting.Attachment.Enabled { + ctx.HTTPError(http.StatusNotFound, "attachment is not enabled") + return + } + + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + return + } + + validExt := strings.Contains(setting.Attachment.AllowedTypes, path.Ext(header.Filename)) + if !validExt { + ctx.HTTPError(http.StatusNotFound, "attachment type not valid") + return + } + ctx.Data["PageIsDiff"] = true + + opts := git_model.AttachmentOptions{ + FileName: header.Filename, + Size: header.Size, + UploaderID: ctx.Doer.ID, + } + + attachmentUUID, err := git_model.SaveTemporaryAttachment(ctx, file, &opts) + if err != nil { + ctx.ServerError("SaveTemporaryAttachment", err) + return + } + + attachmentsMap := ctx.Session.Get("attachmentsMaps") + if attachmentsMap != nil { + attachmentsMap := ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + attachmentsMap[attachmentUUID] = &opts + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } else { + attachmentsMap := make(git_model.AttachmentMap) + attachmentsMap[attachmentUUID] = &opts + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + ctx.JSON(http.StatusOK, map[string]string{ + "uuid": attachmentUUID, + }) +} + +func DeleteCommitAttachment(ctx *context.Context) { + uuid := ctx.FormString("file") + + err := storage.Attachments.Delete(uuid) + if err != nil { + ctx.ServerError("delete ", err) + return + } + + if ctx.Session.Get("attachmentsMaps") != nil { + attachmentsMap := ctx.Session.Get("attachmentsMaps").(git_model.AttachmentMap) + delete(attachmentsMap, uuid) + err = ctx.Session.Set("attachmentsMaps", attachmentsMap) + if err != nil { + ctx.ServerError("UploadCommitAttachment", err) + return + } + } +} + +func GetCommitAttachmentByUUID(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + + repository, err := repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("GetCommitAttachmentByUUID", err) + return + } + uuid := ctx.PathParam("uuid") + + attachment := attachmentMap[uuid] + if attachment == nil { + ctx.HTTPError(http.StatusNotFound) + return + } + if repository == nil { + if !(ctx.IsSigned && attachment.UploaderID == ctx.Doer.ID) { + ctx.HTTPError(http.StatusNotFound) + return + } + } else { + _, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) + if err != nil { + ctx.HTTPError(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + return + } + } + + fr, err := storage.Attachments.Open(uuid) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + common.ServeContentByReadSeeker(ctx.Base, attachment.FileName, util.ToPointer(commitComment.CreatedUnix.AsTime()), fr) +} + +func GetCommitAttachments(ctx *context.Context) { + commitComment, err := git_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + return + } + + repository, err := repo_model.GetRepositoryByID(ctx, commitComment.RefRepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + if repository == nil { + if !(ctx.IsSigned) { + ctx.HTTPError(http.StatusNotFound) + return + } + } + + type AttachmentItem struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Size int64 `json:"size"` + } + + attachmentMap := make(git_model.AttachmentMap) + err = json.Unmarshal([]byte(commitComment.Attachments), &attachmentMap) + if err != nil { + ctx.ServerError("GetCommitAttachments", err) + return + } + + attachmentItems := []*AttachmentItem{} + for key, value := range attachmentMap { + item := &AttachmentItem{ + Name: value.FileName, Size: value.Size, UUID: key, + } + attachmentItems = append(attachmentItems, item) + } + + ctx.JSON(http.StatusOK, attachmentItems) +} diff --git a/routers/web/web.go b/routers/web/web.go index 09be0c39045e0..3492b088b43ac 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1551,6 +1551,26 @@ func registerWebRoutes(m *web.Router) { }, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader) // end "/{username}/{reponame}/pulls/{index}": repo pull request + m.Group("/{username}/{reponame}", func() { + m.Group("/commit/{sha:([a-f0-9]{7,64})$}", func() { + m.Get("", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Post("/attachments", repo.UploadCommitAttachment) + m.Get("/{id}/attachments/{uuid}", repo.GetCommitAttachmentByUUID) + m.Group("/comments", func() { + m.Get("/new_comment", repo.RenderCommitCommentForm) + m.Post("/cancel", repo.CancelCommitComment) + m.Post("/add", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCommitComment) + m.Post("/{id}/delete", repo.DeleteCommitComment) + m.Post("/{id}/update", repo.UpdateCommitComment) + m.Get("/{id}/attachments", repo.GetCommitAttachments) + m.Post("/attachments/remove", repo.DeleteCommitAttachment) + }, context.RepoMustNotBeArchived()) + m.Group("/comments/{id}", func() { + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommitCommentReaction) + }) + }) + }, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader) + m.Group("/{username}/{reponame}", func() { m.Group("/activity_author_data", func() { m.Get("", repo.ActivityAuthors) diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go index 23707950d4a5c..dfabcad16014b 100644 --- a/services/context/upload/upload.go +++ b/services/context/upload/upload.go @@ -4,6 +4,7 @@ package upload import ( + "fmt" "mime" "net/http" "net/url" @@ -89,6 +90,9 @@ func Verify(buf []byte, fileName, allowedTypesStr string) error { // AddUploadContext renders template values for dropzone func AddUploadContext(ctx *context.Context, uploadType string) { + PageIsDiff := ctx.Data["PageIsDiff"] + ctx.Data["CommitSHA"] = ctx.PathParam("sha") + CommitSHA := ctx.PathParam("sha") switch uploadType { case "release": ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments" @@ -98,8 +102,14 @@ func AddUploadContext(ctx *context.Context, uploadType string) { ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize case "comment": - ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" - ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + if PageIsDiff == true { + ctx.Data["UploadUrl"] = fmt.Sprintf("%s/commit/%s/attachments", ctx.Repo.RepoLink, CommitSHA) + ctx.Data["UploadRemoveUrl"] = fmt.Sprintf("%s/commit/%s/comments/attachments/remove", ctx.Repo.RepoLink, CommitSHA) + } else { + ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" + ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + } + if len(ctx.PathParam("index")) > 0 { ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/" + url.PathEscape(ctx.PathParam("index")) + "/attachments" } else { diff --git a/services/git/commit.go b/services/git/commit.go index e4755ef93d7b0..120c07a614561 100644 --- a/services/git/commit.go +++ b/services/git/commit.go @@ -5,6 +5,7 @@ package git import ( "context" + "sort" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" @@ -14,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/gitdiff" ) // ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys. @@ -88,3 +90,65 @@ func ParseCommitsWithStatus(ctx context.Context, oldCommits []*asymkey_model.Sig } return newCommits, nil } + +func CreateCommitComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, opts git_model.CreateCommitCommentOptions) (*git_model.CommitComment, error) { + comment, err := git_model.CreateCommitComment(ctx, &opts) + if err != nil { + return nil, err + } + return comment, nil +} + +// LoadComments loads comments into each line +func LoadCommitComments(ctx context.Context, diff *gitdiff.Diff, commitComment *git_model.CommitComment, currentUser *user_model.User) error { + opts := git_model.FindCommitCommentOptions{ + CommitSHA: commitComment.CommitSHA, + } + + commitComments, err := git_model.FindCommitCommentsByCommit(ctx, &opts, commitComment) + if err != nil { + return err + } + + for _, file := range diff.Files { + for _, cc := range commitComments { + if cc.FileName == file.Name { + for _, section := range file.Sections { + for _, line := range section.Lines { + if cc.Line == int64(line.LeftIdx*-1) { + line.CommitComments = append(line.CommitComments, cc) + cc.Repo = commitComment.Repo + cc.Poster = commitComment.Poster + } + if cc.Line == int64(line.RightIdx) { + line.CommitComments = append(line.CommitComments, cc) + cc.Repo = commitComment.Repo + cc.Poster = commitComment.Poster + } + sort.SliceStable(line.CommitComments, func(i, j int) bool { + return line.CommitComments[i].CreatedUnix < line.CommitComments[j].CreatedUnix + }) + } + } + } + } + } + return nil +} + +// CreateCommentReaction creates a reaction on a comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, commitComment *git_model.CommitComment, reaction string) error { + err := git_model.CreateCommitCommentReaction(ctx, reaction, doer.ID, commitComment) + if err != nil { + return err + } + return nil +} + +func DeleteCommentReaction(ctx context.Context, doer *user_model.User, commitComment *git_model.CommitComment, reaction string) error { + err := git_model.DeleteCommentReaction(ctx, reaction, doer.ID, commitComment) + if err != nil { + return err + } + return nil +} diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index 7c99e049d548a..c16c06c2b56f9 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -77,13 +77,14 @@ const ( // DiffLine represents a line difference in a DiffSection. type DiffLine struct { - LeftIdx int // line number, 1-based - RightIdx int // line number, 1-based - Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side - Type DiffLineType - Content string - Comments issues_model.CommentList // related PR code comments - SectionInfo *DiffLineSectionInfo + LeftIdx int // line number, 1-based + RightIdx int // line number, 1-based + Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side + Type DiffLineType + Content string + Comments issues_model.CommentList // related PR code commzents + CommitComments git_model.CommitCommentList // related to commit comments + SectionInfo *DiffLineSectionInfo } // DiffLineSectionInfo represents diff line section meta data @@ -133,6 +134,11 @@ func (d *DiffLine) CanComment() bool { return len(d.Comments) == 0 && d.Type != DiffLineSection } +// CanCommitComment returns whether a line can get commented +func (d *DiffLine) CanCommitComment() bool { + return len(d.CommitComments) == 0 && d.Type != DiffLineSection +} + // GetCommentSide returns the comment side of the first comment, if not set returns empty string func (d *DiffLine) GetCommentSide() string { if len(d.Comments) == 0 { @@ -141,6 +147,14 @@ func (d *DiffLine) GetCommentSide() string { return d.Comments[0].DiffSide() } +// GetCommentSide returns the comment side of the first comment, if not set returns empty string +func (d *DiffLine) GetCommitCommentSide() string { + if len(d.CommitComments) == 0 { + return "" + } + return d.CommitComments[0].DiffSide() +} + // GetLineTypeMarker returns the line type marker func (d *DiffLine) GetLineTypeMarker() string { if strings.IndexByte(" +-", d.Content[0]) > -1 { diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index e4d1efac57bd9..8ad91e485a420 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -184,7 +184,13 @@ {{end}} {{else}} -