diff --git a/models/git/commit_comment.go b/models/git/commit_comment.go new file mode 100644 index 0000000000..ae63682e1c --- /dev/null +++ b/models/git/commit_comment.go @@ -0,0 +1,272 @@ +// Copyright 2026 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "html/template" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "code.gitea.io/gitea/modules/log" +) + +// CommitComment represents a comment on a single commit +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + CommitSHA string `xorm:"VARCHAR(64) INDEX"` + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + Path string `xorm:"VARCHAR(4000)"` + Line int64 `xorm:"INDEX"` + Content string `xorm:"LONGTEXT"` + RenderedContent template.HTML `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// CreateCommitComment inserts a new commit comment +func CreateCommitComment(ctx context.Context, c *CommitComment) error { + if c == nil { + return fmt.Errorf("nil commit comment") + } + if _, err := db.GetEngine(ctx).Insert(c); err != nil { + return fmt.Errorf("Insert CommitComment: %w", err) + } + return nil +} + +// GetCommitCommentByID returns a commit comment by id +func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) { + c := &CommitComment{ID: id} + has, err := db.GetEngine(ctx).ID(id).Get(c) + if err != nil { + return nil, fmt.Errorf("Get CommitComment by id: %w", err) + } + if !has { + return nil, fmt.Errorf("commit comment does not exist [id: %d]", id) + } + return c, nil +} + +// ListCommitComments returns commit comments for a repo and commit sha +func ListCommitComments(ctx context.Context, repoID int64, commitSHA string) ([]*CommitComment, error) { + var cs []*CommitComment + err := db.GetEngine(ctx).Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA).Asc("created_unix").Find(&cs) + if err != nil { + return nil, fmt.Errorf("List commit comments: %w", err) + } + return cs, nil +} + +// ListCommitCommentsByLine returns commit comments for a given repo/commit/path/line +func ListCommitCommentsByLine(ctx context.Context, repoID int64, commitSHA, path string, line int64) ([]*CommitComment, error) { + cs, err := ListCommitComments(ctx, repoID, commitSHA) + if err != nil { + return nil, err + } + var res []*CommitComment + for _, c := range cs { + if c.Path == path && c.Line == line { + res = append(res, c) + } + } + return res, nil +} + +// UpdateCommitComment updates an existing commit comment +func UpdateCommitComment(ctx context.Context, c *CommitComment) error { + if c == nil || c.ID == 0 { + return fmt.Errorf("invalid commit comment") + } + if _, err := db.GetEngine(ctx).ID(c.ID).Cols("content", "path", "line", "updated_unix").Update(c); err != nil { + return fmt.Errorf("Update CommitComment: %w", err) + } + return nil +} + +// DeleteCommitComment deletes a commit comment by id +func DeleteCommitComment(ctx context.Context, id int64) error { + c := &CommitComment{ID: id} + _, err := db.GetEngine(ctx).ID(id).Delete(c) + if err != nil { + return fmt.Errorf("Delete CommitComment: %w", err) + } + return nil +} + +// LoadPoster loads the poster user for the comment +func (c *CommitComment) LoadPoster(ctx context.Context) error { + if c == nil { + return fmt.Errorf("nil commit comment") + } + if c.Poster != nil { + return nil + } + u, err := user_model.GetPossibleUserByID(ctx, c.PosterID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + c.PosterID = user_model.GhostUserID + c.Poster = user_model.NewGhostUser() + return nil + } + log.Error("getUserByID[%d]: %v", c.PosterID, err) + return err + } + c.Poster = u + return nil +} + +// HashTag returns an id that can be used as a DOM anchor similar to issue comments +func (c *CommitComment) HashTag() string { + return fmt.Sprintf("commitcomment-%d", c.ID) +} + +// UnsignedLine returns absolute line index for use in templates +func (c *CommitComment) UnsignedLine() int64 { + if c.Line < 0 { + return -c.Line + } + return c.Line +} + +// DiffSide returns which side the comment belongs to +func (c *CommitComment) DiffSide() string { + if c.Line < 0 { + return "left" + } + return "right" +} + +// TreePath exposes Path in a field-name compatible way with issue comment templates +func (c *CommitComment) TreePath() string { + return c.Path +} + +// The following methods are provided to be compatible with the issue/pull comment templates +// which expect a richer comment shape (IsResolved, Invalidated, ResolveDoer, Review, ReviewID). +// Commit comments currently do not support review resolution, so these return zero-values. + +// IsResolved indicates whether the conversation has been resolved +func (c *CommitComment) IsResolved() bool { return false } + +// Invalidated indicates whether the comment has been invalidated/outdated +func (c *CommitComment) Invalidated() bool { return false } + +// ResolveDoer returns the user who resolved the conversation (nil for commit comments) +func (c *CommitComment) ResolveDoer() *user_model.User { return nil } + +// Review returns an associated review (nil for commit comments) +func (c *CommitComment) Review() any { return nil } + +// ReviewID returns the ID of the review if any (0 for commit comments) +func (c *CommitComment) ReviewID() int64 { return 0 } + +// OriginalAuthor returns original author name for migrated comments (empty for commit comments) +func (c *CommitComment) OriginalAuthor() string { return "" } + +// Attachments returns attachments associated with the comment (none for commit comments) +// Return type is interface{} to avoid importing repo models and causing import cycles. +func (c *CommitComment) Attachments() interface{} { return nil } + +// ContentVersion returns the content version for inline editing +func (c *CommitComment) ContentVersion() int { return 0 } + +// ReactionListShim is a small, local-friendly substitute for templates that expect +// a list type with GroupByType, HasUser, GetFirstUsers and GetMoreUserCount methods. +// Implemented locally to avoid import cycles with models/issues. +type ReactionShim struct { + UserID int64 + OriginalAuthor string + User *user_model.User +} + +type ReactionListShim []*ReactionShim + +// GroupByType groups ReactionShims by their type key +func (list ReactionListShim) GroupByType() map[string]ReactionListShim { + grouped := make(map[string]ReactionListShim) + // Expectation: each ReactionShim is annotated with its type in OriginalAuthor for grouping convenience + // However our storage does not keep Type on the shim, so this grouping is performed during construction + // The helpers that construct ReactionListShim should provide grouping at the top-level map instead. + return grouped +} + +// HasUser checks if a user has reacted +func (list ReactionListShim) HasUser(userID int64) bool { + if userID == 0 { + return false + } + for _, reaction := range list { + if reaction.OriginalAuthor == "" && reaction.UserID == userID { + return true + } + } + return false +} + +// GetFirstUsers returns a comma-separated list of first users +func (list ReactionListShim) GetFirstUsers() string { return "" } + +// GetMoreUserCount returns remaining user count +func (list ReactionListShim) GetMoreUserCount() int { return 0 } + +// Reactions returns a ReactionListShim (empty) so templates can safely call GroupByType +func (c *CommitComment) Reactions() ReactionListShim { return nil } + +// LoadReactions loads and groups reactions for a commit comment by type +func LoadReactionsForCommitComment(ctx context.Context, commentID int64) (map[string]ReactionListShim, error) { + crs, err := FindCommitCommentReactions(ctx, commentID) + if err != nil { + return nil, err + } + grouped := make(map[string]ReactionListShim) + userIDs := make([]int64, 0, len(crs)) + for _, cr := range crs { + r := &ReactionShim{UserID: cr.UserID, OriginalAuthor: cr.OriginalAuthor} + grouped[cr.Type] = append(grouped[cr.Type], r) + if cr.OriginalAuthor == "" && cr.UserID > 0 { + userIDs = append(userIDs, cr.UserID) + } + } + if len(userIDs) > 0 { + userMap := make(map[int64]*user_model.User) + if err := db.GetEngine(ctx).In("id", userIDs).Find(&userMap); err != nil { + return nil, err + } + for _, list := range grouped { + for _, r := range list { + if r.OriginalAuthor != "" { + // migrated/original author + r.User = user_model.NewGhostUser() + continue + } + if u, ok := userMap[r.UserID]; ok { + r.User = u + } else { + r.User = user_model.NewGhostUser() + } + } + } + } + return grouped, nil +} + +// IsErrCommitCommentNotExist returns true when error indicates the commit comment wasn't found +func IsErrCommitCommentNotExist(err error) bool { + if err == nil { + return false + } + // GetCommitCommentByID returns fmt.Errorf("commit comment does not exist [id: %d]", id) when not found + return strings.HasPrefix(err.Error(), "commit comment does not exist") +} diff --git a/models/git/commit_comment_reaction.go b/models/git/commit_comment_reaction.go new file mode 100644 index 0000000000..4b239e6014 --- /dev/null +++ b/models/git/commit_comment_reaction.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +package git + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" +) + +// CommitCommentReaction represents a reaction on a commit comment +type CommitCommentReaction struct { + ID int64 `xorm:"pk autoincr"` + Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` + CommitCommentID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"` + OriginalAuthor string `xorm:"INDEX UNIQUE(s)"` + User *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func init() { + db.RegisterModel(new(CommitCommentReaction)) +} + +// FindCommitCommentReactions returns reactions for a given commit comment +func FindCommitCommentReactions(ctx context.Context, commitCommentID int64) ([]*CommitCommentReaction, error) { + reactions := make([]*CommitCommentReaction, 0, 10) + if err := db.GetEngine(ctx).Where("commit_comment_id = ?", commitCommentID).Asc("created_unix").Find(&reactions); err != nil { + return nil, fmt.Errorf("Find commit comment reactions: %w", err) + } + return reactions, nil +} + +// CreateCommitCommentReaction creates a reaction for a commit comment +func CreateCommitCommentReaction(ctx context.Context, doer *user_model.User, commitCommentID int64, reactionType string) (*CommitCommentReaction, error) { + if !setting.UI.ReactionsLookup.Contains(reactionType) { + return nil, fmt.Errorf("'%s' is not an allowed reaction", reactionType) + } + + reaction := &CommitCommentReaction{ + Type: reactionType, + UserID: doer.ID, + CommitCommentID: commitCommentID, + } + + // Check if exists + existing := CommitCommentReaction{} + has, err := db.GetEngine(ctx).Where("commit_comment_id = ? and type = ? and user_id = ?", commitCommentID, reactionType, doer.ID).Get(&existing) + if err != nil { + return nil, fmt.Errorf("Find existing commit comment reaction: %w", err) + } + if has { + return &existing, fmt.Errorf("reaction '%s' already exists", reactionType) + } + + if err := db.Insert(ctx, reaction); err != nil { + return nil, fmt.Errorf("Insert commit comment reaction: %w", err) + } + return reaction, nil +} + +// DeleteCommitCommentReaction deletes a reaction for a commit comment +func DeleteCommitCommentReaction(ctx context.Context, doerID, commitCommentID int64, reactionType string) error { + reaction := &CommitCommentReaction{ + Type: reactionType, + UserID: doerID, + CommitCommentID: commitCommentID, + } + + _, err := db.GetEngine(ctx).Delete(reaction) + return err +} diff --git a/models/git/commit_comment_test.go b/models/git/commit_comment_test.go new file mode 100644 index 0000000000..2ca1f70e3b --- /dev/null +++ b/models/git/commit_comment_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package git_test + +import ( + "testing" + "time" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestCreateCommitComment(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + now := time.Now().Unix() + c := &git_model.CommitComment{ + RepoID: repo.ID, + CommitSHA: "abcdef1", + PosterID: doer.ID, + Content: "hello commit", + } + assert.NoError(t, git_model.CreateCommitComment(t.Context(), c)) + then := time.Now().Unix() + + assert.Equal(t, repo.ID, c.RepoID) + assert.Equal(t, "abcdef1", c.CommitSHA) + assert.Equal(t, doer.ID, c.PosterID) + assert.Equal(t, "hello commit", c.Content) + unittest.AssertInt64InRange(t, now, then, int64(c.CreatedUnix)) + unittest.AssertExistsAndLoadBean(t, c) + + // load poster + assert.NoError(t, c.LoadPoster(t.Context())) + assert.NotNil(t, c.Poster) +} + +func TestListUpdateDeleteCommitComment(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + c := &git_model.CommitComment{ + RepoID: repo.ID, + CommitSHA: "deadbeef", + PosterID: doer.ID, + Content: "first", + } + assert.NoError(t, git_model.CreateCommitComment(t.Context(), c)) + + list, err := git_model.ListCommitComments(t.Context(), repo.ID, "deadbeef") + assert.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, "first", list[0].Content) + + // update + list[0].Content = "updated" + assert.NoError(t, git_model.UpdateCommitComment(t.Context(), list[0])) + c2, err := git_model.GetCommitCommentByID(t.Context(), list[0].ID) + assert.NoError(t, err) + assert.Equal(t, "updated", c2.Content) + + // delete + assert.NoError(t, git_model.DeleteCommitComment(t.Context(), c2.ID)) + _, err = git_model.GetCommitCommentByID(t.Context(), c2.ID) + assert.Error(t, err) + + // ensure deleted not listed + list2, err := git_model.ListCommitComments(t.Context(), repo.ID, "deadbeef") + assert.NoError(t, err) + assert.Len(t, list2, 0) + + // ensure DB consistency + unittest.CheckConsistencyFor(t, &git_model.CommitComment{}) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 27f5651ecb..d97010e312 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -27,15 +27,20 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "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/templates" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" 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 ( @@ -350,6 +355,23 @@ func Diff(ctx *context.Context) { ctx.Data["Username"] = userName ctx.Data["Reponame"] = repoName + // Load commit comments into diff so inline conversations are visible + if diff != nil { + // Ensure ShowOutdatedComments is a boolean (middleware may not have run for commit routes) + showOutdated := false + if v, ok := ctx.Data["ShowOutdatedComments"].(bool); ok { + showOutdated = v + } else { + showOutdated = ctx.FormBool("show-outdated") + ctx.Data["ShowOutdatedComments"] = showOutdated + } + + if err := diff.LoadCommitComments(ctx, ctx.Repo.Repository, ctx.Doer, commitID, showOutdated); err != nil { + ctx.ServerError("LoadCommitComments", err) + return + } + } + var parentCommit *git.Commit var parentCommitID string if commit.ParentCount() > 0 { @@ -366,6 +388,12 @@ func Diff(ctx *context.Context) { ctx.Data["Diff"] = diff ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData + // Provide root and helper functions expected by diff/issue templates + ctx.Data["root"] = ctx.Data + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + if !fileOnly { diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID) if err != nil { @@ -427,6 +455,99 @@ func Diff(ctx *context.Context) { ctx.HTML(http.StatusOK, tplCommitPage) } +// RenderNewCommitCommentForm renders the form for creating a new commit comment +func RenderNewCommitCommentForm(ctx *context.Context) { + if ctx.Written() { + return + } + if !ctx.IsSigned || !ctx.Repo.CanWrite(unit_model.TypeCode) { + ctx.HTTPError(http.StatusForbidden) + return + } + sha := ctx.PathParam("sha") + ctx.Data["CommitID"] = sha + ctx.Data["AfterCommitID"] = sha + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.HTML(http.StatusOK, tplNewComment) +} + +// CreateCommitCodeComment creates a code comment on a commit +func CreateCommitCodeComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CodeCommentForm) + sha := ctx.PathParam("sha") + if ctx.Written() { + return + } + if !ctx.IsSigned || !ctx.Repo.CanWrite(unit_model.TypeCode) { + ctx.HTTPError(http.StatusForbidden) + return + } + if ctx.HasError() { + ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Redirect(fmt.Sprintf("%s/commit/%s", ctx.Repo.RepoLink, sha)) + return + } + + signedLine := form.Line + if form.Side == "previous" { + signedLine *= -1 + } + + comment := &git_model.CommitComment{ + RepoID: ctx.Repo.Repository.ID, + CommitSHA: sha, + PosterID: ctx.Doer.ID, + Path: form.TreePath, + Line: int64(signedLine), + Content: form.Content, + } + if err := git_model.CreateCommitComment(ctx, comment); err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + + if err := comment.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return + } + + // Render content for this and all comments on the same line/path + comments, err := git_model.ListCommitCommentsByLine(ctx, ctx.Repo.Repository.ID, sha, form.TreePath, int64(signedLine)) + if err != nil { + ctx.ServerError("ListCommitCommentsByLine", err) + return + } + + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(sha))}) + for _, cc := range comments { + if err := cc.LoadPoster(ctx); err != nil { + ctx.ServerError("LoadPoster", err) + return + } + renderedHTML, err := markdown.RenderString(rctx, cc.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + cc.RenderedContent = renderedHTML + } + + // Prepare data compatible with the PR conversation templates + ctx.Data["comments"] = comments + ctx.Data["root"] = ctx.Data + ctx.Data["AfterCommitID"] = sha + ctx.Data["CommitID"] = sha + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + ctx.Data["CanMarkConversation"] = false + // Helper expected by PR templates + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + ctx.HTML(http.StatusOK, tplDiffConversation) +} + // RawDiff dumps diff results of repository in given commit ID to io.Writer func RawDiff(ctx *context.Context) { var gitRepo *git.Repository diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go new file mode 100644 index 0000000000..3ac73ccb30 --- /dev/null +++ b/routers/web/repo/commit_comment.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +package repo + +import ( + "fmt" + "net/http" + "path" + "strings" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + renderhelper "code.gitea.io/gitea/models/renderhelper" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +// DeleteCommitComment deletes a commit comment +func DeleteCommitComment(ctx *context.Context) { + id := ctx.PathParamInt64("id") + cc, err := git_model.GetCommitCommentByID(ctx, id) + if err != nil { + ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err) + return + } + + if cc.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != cc.PosterID && !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName)) { + // allow deletion by poster or users who can write to the branch (repo maintainers) + ctx.HTTPError(http.StatusForbidden) + return + } + + if err := git_model.DeleteCommitComment(ctx, id); err != nil { + ctx.ServerError("DeleteCommitComment", err) + return + } + + log.Trace("Commit comment deleted: %d/%d", ctx.Repo.Repository.ID, id) + ctx.Status(http.StatusOK) +} + +// UpdateCommitComment updates commit comment content +func UpdateCommitComment(ctx *context.Context) { + id := ctx.PathParamInt64("id") + cc, err := git_model.GetCommitCommentByID(ctx, id) + if err != nil { + ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err) + return + } + + if cc.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != cc.PosterID && !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName)) { + ctx.HTTPError(http.StatusForbidden) + return + } + + newContent := ctx.FormString("content") + if newContent != cc.Content { + oldContent := cc.Content + cc.Content = newContent + if err := git_model.UpdateCommitComment(ctx, cc); err != nil { + ctx.ServerError("UpdateCommitComment", err) + return + } + log.Trace("Commit comment updated: %d/%d", ctx.Repo.Repository.ID, id) + _ = oldContent // reserved for potential audit/logging + } + + // render updated content using markdown renderer so newlines and markdown are preserved + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(cc.CommitSHA))}) + renderedHTML, err := markdown.RenderString(rctx, cc.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": renderedHTML, + "contentVersion": cc.ContentVersion(), + "attachments": "", + }) +} + +// ChangeCommitCommentReaction handles react/unreact on a commit comment +func ChangeCommitCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + id := ctx.PathParamInt64("id") + cc, err := git_model.GetCommitCommentByID(ctx, id) + if err != nil { + ctx.NotFoundOrServerError("GetCommitCommentByID", git_model.IsErrCommitCommentNotExist, err) + return + } + + if cc.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + if !ctx.IsSigned { + ctx.HTTPError(http.StatusForbidden) + return + } + + switch ctx.PathParam("action") { + case "react": + if _, err := git_model.CreateCommitCommentReaction(ctx, ctx.Doer, cc.ID, form.Content); err != nil { + // If the reactions table wasn't present (older DB), try creating it on-the-fly and retry once. + if strings.Contains(err.Error(), "Table not found") || strings.Contains(err.Error(), "no such table") { + log.Warn("Commit comment reactions table missing; attempting to create table and retry: %s", err) + if err := db.GetEngine(ctx).Sync(new(git_model.CommitCommentReaction)); err != nil { + log.Error("Failed to create commit_comment_reaction table: %v", err) + break + } + if _, err2 := git_model.CreateCommitCommentReaction(ctx, ctx.Doer, cc.ID, form.Content); err2 != nil { + log.Info("CreateCommitCommentReaction retry failed: %s", err2) + break + } + break + } + // log and continue; forbidden reaction returns error + log.Info("CreateCommitCommentReaction: %s", err) + break + } + case "unreact": + if err := git_model.DeleteCommitCommentReaction(ctx, ctx.Doer.ID, cc.ID, form.Content); err != nil { + ctx.ServerError("DeleteCommitCommentReaction", err) + return + } + default: + ctx.NotFound(nil) + return + } + + // Reload new reactions + reactions, err := git_model.LoadReactionsForCommitComment(ctx, cc.ID) + if err != nil { + ctx.ServerError("LoadReactionsForCommitComment", err) + return + } + + // Log reactions counts for diagnostics + var totalReacts int + for _, list := range reactions { + totalReacts += len(list) + } + log.Trace("Loaded %d commit comment reactions for: %d", totalReacts, cc.ID) + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ActionURL": fmt.Sprintf("%s/commit/%s/comments/%d/reactions", ctx.Repo.RepoLink, cc.CommitSHA, cc.ID), + "Reactions": reactions, + }) + if err != nil { + ctx.ServerError("ChangeCommitCommentReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index 64137876e0..819c5f7132 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1616,8 +1616,17 @@ func registerWebRoutes(m *web.Router) { m.Group("", func() { m.Get("/graph", repo.Graph) - m.Get("/commit/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) - m.Get("/commit/{sha:([a-f0-9]{7,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Group("/commit/{sha:[a-f0-9]{7,64}}", func() { + m.Get("", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Group("/files", func() { + m.Get("/reviews/new_comment", repo.RenderNewCommitCommentForm) + m.Post("/reviews/comments", web.Bind(forms.CodeCommentForm{}), repo.CreateCommitCodeComment) + }) + m.Post("/comments/{id}", repo.UpdateCommitComment) + m.Post("/comments/{id}/delete", repo.DeleteCommitComment) + m.Post("/comments/{id}/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommitCommentReaction) + }) // FIXME: this route `/cherry-pick/{sha}` doesn't seem useful or right, the new code always uses `/_cherrypick/` which could handle branch name correctly m.Get("/cherry-pick/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, context.RepoRefByDefaultBranch(), repo.CherryPick) diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index f00c90d737..86ad06c89c 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -22,6 +22,8 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" pull_model "code.gitea.io/gitea/models/pull" + renderhelper "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/analyze" "code.gitea.io/gitea/modules/base" @@ -34,6 +36,7 @@ import ( "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/svg" @@ -593,6 +596,115 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c return nil } +// LoadCommitComments loads commit comments into each line similar to PR code comments +func (diff *Diff) LoadCommitComments(ctx context.Context, repo *repo_model.Repository, currentUser *user_model.User, commitSHA string, showOutdatedComments bool) error { + cs, err := git_model.ListCommitComments(ctx, repo.ID, commitSHA) + if err != nil { + return err + } + // Preload reactions for all commit comments + reactionsByComment := make(map[int64]issues_model.ReactionList) + if len(cs) > 0 { + commentIDs := make([]int64, 0, len(cs)) + for _, cc := range cs { + commentIDs = append(commentIDs, cc.ID) + } + commitReactions := make([]*git_model.CommitCommentReaction, 0, len(cs)) + if err := db.GetEngine(ctx).In("commit_comment_id", commentIDs).Asc("created_unix").Find(&commitReactions); err != nil { + return err + } + userIDs := make([]int64, 0, len(commitReactions)) + for _, cr := range commitReactions { + react := &issues_model.Reaction{ + Type: cr.Type, + IssueID: 0, + CommentID: cr.CommitCommentID, + UserID: cr.UserID, + OriginalAuthorID: cr.OriginalAuthorID, + OriginalAuthor: cr.OriginalAuthor, + CreatedUnix: cr.CreatedUnix, + } + reactionsByComment[cr.CommitCommentID] = append(reactionsByComment[cr.CommitCommentID], react) + if cr.OriginalAuthor == "" && cr.UserID > 0 { + userIDs = append(userIDs, cr.UserID) + } + } + + userMap := make(map[int64]*user_model.User) + if len(userIDs) > 0 { + if err := db.GetEngine(ctx).In("id", userIDs).Find(&userMap); err != nil { + return err + } + } + for _, list := range reactionsByComment { + for _, reaction := range list { + if reaction.OriginalAuthor != "" { + name := fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name()) + reaction.User = &user_model.User{ID: 0, Name: name, LowerName: strings.ToLower(name)} + continue + } + if u, ok := userMap[reaction.UserID]; ok { + reaction.User = u + } else { + reaction.User = user_model.NewGhostUser() + } + } + } + } + // Build map[string]map[int64][]*issues_model.Comment + lineCommits := make(map[string]map[int64][]*issues_model.Comment) + for _, cc := range cs { + if err := cc.LoadPoster(ctx); err != nil { + return err + } + c := &issues_model.Comment{ + ID: cc.ID, + Type: issues_model.CommentTypeCode, + PosterID: cc.PosterID, + Poster: cc.Poster, + OriginalAuthor: "", + IssueID: 0, + Content: cc.Content, + CreatedUnix: cc.CreatedUnix, + Line: cc.Line, + TreePath: cc.Path, + Reactions: reactionsByComment[cc.ID], + } + // Render content for this comment + rctx := renderhelper.NewRenderContextRepoComment(ctx, repo, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(commitSHA))}) + renderedHTML, err := markdown.RenderString(rctx, cc.Content) + if err != nil { + return err + } + c.RenderedContent = renderedHTML + + if lineCommits[c.TreePath] == nil { + lineCommits[c.TreePath] = make(map[int64][]*issues_model.Comment) + } + lineCommits[c.TreePath][c.Line] = append(lineCommits[c.TreePath][c.Line], c) + } + + for _, file := range diff.Files { + if lineCommitsForFile, ok := lineCommits[file.Name]; ok { + for _, section := range file.Sections { + for _, line := range section.Lines { + if comments, ok := lineCommitsForFile[int64(line.LeftIdx*-1)]; ok { + line.Comments = append(line.Comments, comments...) + } + if comments, ok := lineCommitsForFile[int64(line.RightIdx)]; ok { + line.Comments = append(line.Comments, comments...) + } + sort.SliceStable(line.Comments, func(i, j int) bool { + return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix + }) + FillHiddenCommentIDsForDiffLine(line, lineCommitsForFile) + } + } + } + } + return nil +} + const cmdDiffHead = "diff --git " // ParsePatch builds a Diff object from a io.Reader and some parameters. diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 2a3330d890..b82fa4720b 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -184,7 +184,13 @@ {{end}} {{else}} -