diff --git a/models/issues/comment.go b/models/issues/comment.go index 25e74c01ea..1e326d8a21 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -116,6 +116,8 @@ const ( CommentTypeUnpin // 37 unpin Issue/PullRequest CommentTypeChangeTimeEstimate // 38 Change time estimate + + CommentTypeCommitComment // 39 Inline comment on a commit diff (not part of a PR review) ) var commentStrings = []string{ @@ -158,6 +160,7 @@ var commentStrings = []string{ "pin", "unpin", "change_time_estimate", + "commit_comment", } func (t CommentType) String() string { @@ -175,7 +178,7 @@ func AsCommentType(typeName string) CommentType { func (t CommentType) HasContentSupport() bool { switch t { - case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview: + case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeCommitComment: return true } return false @@ -183,7 +186,7 @@ func (t CommentType) HasContentSupport() bool { func (t CommentType) HasAttachmentSupport() bool { switch t { - case CommentTypeComment, CommentTypeCode, CommentTypeReview: + case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeCommitComment: return true } return false diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go new file mode 100644 index 0000000000..2b4e38a43f --- /dev/null +++ b/models/issues/commit_comment.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// CommitComment is a junction table linking a commit (repo + SHA) to +// a Comment entry. The comment content, tree_path, line, poster, etc. +// are stored in the Comment table with Type = CommentTypeCommitComment. +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + CommentID int64 `xorm:"UNIQUE NOT NULL"` +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// FileCommitComments holds commit comments for a single file, +// split by side (left = old, right = new) with int keys matching DiffLine indices. +type FileCommitComments struct { + Left map[int][]*Comment + Right map[int][]*Comment +} + +// CommitCommentsForDiff maps file paths to their commit comments. +type CommitCommentsForDiff map[string]*FileCommitComments + +// FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. +func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*Comment, error) { + var refs []CommitComment + if err := db.GetEngine(ctx). + Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). + Find(&refs); err != nil { + return nil, err + } + + if len(refs) == 0 { + return nil, nil + } + + commentIDs := make([]int64, 0, len(refs)) + for _, ref := range refs { + commentIDs = append(commentIDs, ref.CommentID) + } + + comments := make([]*Comment, 0, len(commentIDs)) + if err := db.GetEngine(ctx). + In("id", commentIDs). + OrderBy("created_unix ASC"). + Find(&comments); err != nil { + return nil, err + } + + for _, c := range comments { + if err := c.LoadPoster(ctx); err != nil { + return nil, err + } + } + + return comments, nil +} + +// FindCommitCommentsForDiff returns comments grouped by path and side for rendering in a diff view. +func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA string) (CommitCommentsForDiff, error) { + comments, err := FindCommitCommentsByCommitSHA(ctx, repoID, commitSHA) + if err != nil { + return nil, err + } + + result := make(CommitCommentsForDiff) + for _, c := range comments { + fcc, ok := result[c.TreePath] + if !ok { + fcc = &FileCommitComments{ + Left: make(map[int][]*Comment), + Right: make(map[int][]*Comment), + } + result[c.TreePath] = fcc + } + if c.Line < 0 { + idx := int(-c.Line) + fcc.Left[idx] = append(fcc.Left[idx], c) + } else { + idx := int(c.Line) + fcc.Right[idx] = append(fcc.Right[idx], c) + } + } + return result, nil +} + +// CreateCommitComment creates a Comment with type CommitComment and a +// corresponding CommitComment junction record, within a transaction. +func CreateCommitComment(ctx context.Context, repoID int64, commitSHA string, comment *Comment) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := db.GetEngine(ctx).Insert(comment); err != nil { + return err + } + + ref := &CommitComment{ + RepoID: repoID, + CommitSHA: commitSHA, + CommentID: comment.ID, + } + _, err := db.GetEngine(ctx).Insert(ref) + return err + }) +} + +// DeleteCommitComment deletes both the junction record and the Comment entry. +func DeleteCommitComment(ctx context.Context, commentID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := db.GetEngine(ctx).Where("comment_id = ?", commentID).Delete(&CommitComment{}); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(commentID).Delete(&Comment{}) + return err + }) +} + +// GetCommitCommentByID returns a commit comment by loading the Comment entry. +func GetCommitCommentByID(ctx context.Context, commentID int64) (*Comment, error) { + c := &Comment{} + has, err := db.GetEngine(ctx).ID(commentID).Get(c) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "CommitComment", ID: commentID} + } + return c, nil +} diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 387a183bbc..3c3a751aa7 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -4,23 +4,19 @@ package v1_26 import ( - "code.gitea.io/gitea/modules/timeutil" - "xorm.io/xorm" ) func AddCommitCommentTable(x *xorm.Engine) error { + // CommitComment is a junction table that maps commit-specific context + // (repo, commit SHA) to a Comment entry. The actual comment content, + // tree_path, line, poster, etc. live in the Comment table with + // type = CommentTypeCommitComment (39). type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - TreePath string `xorm:"VARCHAR(4000) NOT NULL"` - Line int64 `xorm:"NOT NULL"` - PosterID int64 `xorm:"INDEX NOT NULL"` - Content string `xorm:"LONGTEXT NOT NULL"` - Patch string `xorm:"LONGTEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + CommentID int64 `xorm:"UNIQUE NOT NULL"` } return x.Sync(new(CommitComment)) diff --git a/models/repo/commit_comment.go b/models/repo/commit_comment.go index e13d127bc1..74657a72a0 100644 --- a/models/repo/commit_comment.go +++ b/models/repo/commit_comment.go @@ -1,155 +1,8 @@ // Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT +// This file intentionally left minimal. The CommitComment junction table +// and all query methods now live in models/issues/commit_comment.go +// alongside the Comment model they reference. + package repo - -import ( - "context" - "fmt" - "html/template" - - "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/timeutil" -) - -// CommitComment represents an inline comment on a commit diff. -type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - TreePath string `xorm:"VARCHAR(4000) NOT NULL"` - Line int64 `xorm:"NOT NULL"` // negative = old side, positive = new side - PosterID int64 `xorm:"INDEX NOT NULL"` - Poster *user_model.User `xorm:"-"` - Content string `xorm:"LONGTEXT NOT NULL"` - RenderedContent template.HTML `xorm:"-"` - Patch string `xorm:"LONGTEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` -} - -func init() { - db.RegisterModel(new(CommitComment)) -} - -// HashTag returns a unique tag for the comment, used for anchoring. -func (c *CommitComment) HashTag() string { - return fmt.Sprintf("commitcomment-%d", c.ID) -} - -// UnsignedLine returns the absolute value of the line number. -func (c *CommitComment) UnsignedLine() int64 { - if c.Line < 0 { - return -c.Line - } - return c.Line -} - -// GetCommentSide returns "previous" for old side (negative Line), "proposed" for new side. -func (c *CommitComment) GetCommentSide() string { - if c.Line < 0 { - return "previous" - } - return "proposed" -} - -// LoadPoster loads the poster user for a commit comment. -func (c *CommitComment) LoadPoster(ctx context.Context) error { - if c.Poster != nil || c.PosterID <= 0 { - return nil - } - poster, err := user_model.GetUserByID(ctx, c.PosterID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - c.PosterID = user_model.GhostUserID - c.Poster = user_model.NewGhostUser() - return nil - } - return err - } - c.Poster = poster - return nil -} - -// FileCommitComments holds commit comments for a single file, -// split by side (left = old, right = new) with int keys matching DiffLine indices. -type FileCommitComments struct { - Left map[int][]*CommitComment - Right map[int][]*CommitComment -} - -// CommitCommentsForDiff maps file paths to their commit comments. -type CommitCommentsForDiff map[string]*FileCommitComments - -// FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. -func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*CommitComment, error) { - comments := make([]*CommitComment, 0, 10) - return comments, db.GetEngine(ctx). - Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - OrderBy("created_unix ASC"). - Find(&comments) -} - -// FindCommitCommentsForDiff returns comments grouped by path and side for rendering in a diff view. -func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA string) (CommitCommentsForDiff, error) { - comments, err := FindCommitCommentsByCommitSHA(ctx, repoID, commitSHA) - if err != nil { - return nil, err - } - - result := make(CommitCommentsForDiff) - for _, c := range comments { - if err := c.LoadPoster(ctx); err != nil { - return nil, err - } - fcc, ok := result[c.TreePath] - if !ok { - fcc = &FileCommitComments{ - Left: make(map[int][]*CommitComment), - Right: make(map[int][]*CommitComment), - } - result[c.TreePath] = fcc - } - if c.Line < 0 { - idx := int(-c.Line) - fcc.Left[idx] = append(fcc.Left[idx], c) - } else { - idx := int(c.Line) - fcc.Right[idx] = append(fcc.Right[idx], c) - } - } - return result, nil -} - -// CreateCommitComment inserts a new commit comment. -func CreateCommitComment(ctx context.Context, c *CommitComment) error { - _, err := db.GetEngine(ctx).Insert(c) - return err -} - -// GetCommitCommentByID returns a commit comment by its ID. -func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) { - c := &CommitComment{} - has, err := db.GetEngine(ctx).ID(id).Get(c) - if err != nil { - return nil, err - } - if !has { - return nil, db.ErrNotExist{Resource: "CommitComment", ID: id} - } - return c, nil -} - -// DeleteCommitComment deletes a commit comment by ID. -func DeleteCommitComment(ctx context.Context, id int64) error { - _, err := db.GetEngine(ctx).ID(id).Delete(&CommitComment{}) - return err -} - -// CountCommitCommentsByCommitSHA returns the count of comments for a commit. -func CountCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) (int64, error) { - return db.GetEngine(ctx). - Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - Count(&CommitComment{}) -} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 08745a8d6e..4a9a1b2b76 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -426,7 +426,7 @@ func Diff(ctx *context.Context) { } // Load inline commit comments for the diff view - commitComments, err := repo_model.FindCommitCommentsForDiff(ctx, ctx.Repo.Repository.ID, commitID) + commitComments, err := issues_model.FindCommitCommentsForDiff(ctx, ctx.Repo.Repository.ID, commitID) if err != nil { log.Error("FindCommitCommentsForDiff: %v", err) } diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index 9b906064f0..44bd24fd5a 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -6,8 +6,8 @@ package repo import ( "net/http" + issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" - repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -51,12 +51,10 @@ func CreateCommitComment(ctx *context.Context) { return } - // Negate line number for "previous" (old) side if side == "previous" { line = -line } - // Resolve full commit SHA commit, err := ctx.Repo.GitRepo.GetCommit(commitSHA) if err != nil { if git.IsErrNotExist(err) { @@ -98,18 +96,18 @@ func CreateCommitComment(ctx *context.Context) { } } - comment := &repo_model.CommitComment{ - RepoID: ctx.Repo.Repository.ID, + comment := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitComment, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, CommitSHA: fullSHA, - TreePath: treePath, - Line: line, - PosterID: ctx.Doer.ID, - Poster: ctx.Doer, - Content: content, - Patch: patch, + TreePath: treePath, + Line: line, + Content: content, + Patch: patch, } - if err := repo_model.CreateCommitComment(ctx, comment); err != nil { + if err := issues_model.CreateCommitComment(ctx, ctx.Repo.Repository.ID, fullSHA, comment); err != nil { ctx.ServerError("CreateCommitComment", err) return } @@ -121,9 +119,8 @@ func CreateCommitComment(ctx *context.Context) { log.Error("RenderString for commit comment %d: %v", comment.ID, err) } - // Return the conversation HTML so JS can replace the form inline ctx.Data["CommitID"] = fullSHA - ctx.Data["comments"] = []*repo_model.CommitComment{comment} + ctx.Data["comments"] = []*issues_model.Comment{comment} ctx.HTML(http.StatusOK, tplCommitConversation) } @@ -135,19 +132,18 @@ func DeleteCommitComment(ctx *context.Context) { return } - comment, err := repo_model.GetCommitCommentByID(ctx, commentID) + comment, err := issues_model.GetCommitCommentByID(ctx, commentID) if err != nil { ctx.NotFound(err) return } - // Only the poster or repo admin can delete if comment.PosterID != ctx.Doer.ID && !ctx.Repo.IsAdmin() { ctx.JSONError("permission denied") return } - if err := repo_model.DeleteCommitComment(ctx, commentID); err != nil { + if err := issues_model.DeleteCommitComment(ctx, commentID); err != nil { ctx.ServerError("DeleteCommitComment", err) return } diff --git a/templates/repo/diff/commit_conversation.tmpl b/templates/repo/diff/commit_conversation.tmpl index bc6e689d3f..9577091693 100644 --- a/templates/repo/diff/commit_conversation.tmpl +++ b/templates/repo/diff/commit_conversation.tmpl @@ -1,6 +1,6 @@ {{if .comments}} {{$comment := index .comments 0}} -
+