Skip to content

Commit ced02fa

Browse files
committed
feat(commit_comment): Add inline comments on commit diffs
1 parent 0ce7d66 commit ced02fa

File tree

17 files changed

+918
-14
lines changed

17 files changed

+918
-14
lines changed

models/migrations/migrations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
398398
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
399399

400400
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
401+
newMigration(324, "Add commit comment table", v1_26.AddCommitCommentTable),
401402
}
402403
return preparedMigrations
403404
}

models/migrations/v1_26/v324.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_26
5+
6+
import (
7+
"code.gitea.io/gitea/modules/timeutil"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
func AddCommitCommentTable(x *xorm.Engine) error {
13+
type CommitComment struct {
14+
ID int64 `xorm:"pk autoincr"`
15+
RepoID int64 `xorm:"INDEX"`
16+
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
17+
TreePath string `xorm:"VARCHAR(4000)"`
18+
Line int64
19+
Content string `xorm:"LONGTEXT"`
20+
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
21+
PosterID int64 `xorm:"INDEX"`
22+
OriginalAuthor string
23+
OriginalAuthorID int64
24+
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
25+
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
26+
}
27+
28+
return x.Sync2(new(CommitComment))
29+
}

models/repo/commit_comment.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"html/template"
10+
11+
"code.gitea.io/gitea/models/db"
12+
user_model "code.gitea.io/gitea/models/user"
13+
"code.gitea.io/gitea/modules/timeutil"
14+
15+
"xorm.io/builder"
16+
)
17+
18+
// CommitComment represents a comment on a specific line in a commit diff
19+
type CommitComment struct {
20+
ID int64 `xorm:"pk autoincr"`
21+
RepoID int64 `xorm:"INDEX"`
22+
Repo *Repository `xorm:"-"`
23+
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
24+
TreePath string `xorm:"VARCHAR(4000)"` // File path (same field name as issue comments)
25+
Line int64 // - previous line / + proposed line
26+
Content string `xorm:"LONGTEXT"`
27+
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
28+
RenderedContent template.HTML `xorm:"-"`
29+
PosterID int64 `xorm:"INDEX"`
30+
Poster *user_model.User `xorm:"-"`
31+
OriginalAuthor string
32+
OriginalAuthorID int64
33+
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
34+
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
35+
Attachments []*Attachment `xorm:"-"`
36+
37+
// Fields for template compatibility with PR comments
38+
Review any `xorm:"-"` // Always nil for commit comments
39+
Invalidated bool `xorm:"-"` // Always false for commit comments
40+
ResolveDoer any `xorm:"-"` // Always nil for commit comments
41+
Reactions any `xorm:"-"` // Reactions for this comment
42+
}
43+
44+
// IsResolved returns false (commit comments don't support resolution)
45+
func (c *CommitComment) IsResolved() bool {
46+
return false
47+
}
48+
49+
// HasOriginalAuthor returns if a comment was migrated and has an original author
50+
func (c *CommitComment) HasOriginalAuthor() bool {
51+
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
52+
}
53+
54+
func init() {
55+
db.RegisterModel(new(CommitComment))
56+
}
57+
58+
// ErrCommitCommentNotExist represents a "CommitCommentNotExist" kind of error.
59+
type ErrCommitCommentNotExist struct {
60+
ID int64
61+
}
62+
63+
// IsErrCommitCommentNotExist checks if an error is a ErrCommitCommentNotExist.
64+
func IsErrCommitCommentNotExist(err error) bool {
65+
_, ok := err.(ErrCommitCommentNotExist)
66+
return ok
67+
}
68+
69+
func (err ErrCommitCommentNotExist) Error() string {
70+
return fmt.Sprintf("commit comment does not exist [id: %d]", err.ID)
71+
}
72+
73+
// CreateCommitComment creates a new commit comment
74+
func CreateCommitComment(ctx context.Context, comment *CommitComment) error {
75+
return db.Insert(ctx, comment)
76+
}
77+
78+
// GetCommitCommentByID returns a commit comment by ID
79+
func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) {
80+
comment := new(CommitComment)
81+
has, err := db.GetEngine(ctx).ID(id).Get(comment)
82+
if err != nil {
83+
return nil, err
84+
} else if !has {
85+
return nil, ErrCommitCommentNotExist{id}
86+
}
87+
return comment, nil
88+
}
89+
90+
// FindCommitCommentsOptions describes the conditions to find commit comments
91+
type FindCommitCommentsOptions struct {
92+
db.ListOptions
93+
RepoID int64
94+
CommitSHA string
95+
Path string
96+
Line int64
97+
}
98+
99+
// ToConds implements FindOptions interface
100+
func (opts FindCommitCommentsOptions) ToConds() builder.Cond {
101+
cond := builder.NewCond()
102+
if opts.RepoID > 0 {
103+
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
104+
}
105+
if opts.CommitSHA != "" {
106+
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
107+
}
108+
if opts.Path != "" {
109+
cond = cond.And(builder.Eq{"tree_path": opts.Path})
110+
}
111+
if opts.Line != 0 {
112+
cond = cond.And(builder.Eq{"line": opts.Line})
113+
}
114+
return cond
115+
}
116+
117+
// FindCommitComments returns commit comments based on options
118+
func FindCommitComments(ctx context.Context, opts FindCommitCommentsOptions) ([]*CommitComment, error) {
119+
comments := make([]*CommitComment, 0, 10)
120+
sess := db.GetEngine(ctx).Where(opts.ToConds())
121+
if opts.Page > 0 {
122+
sess = db.SetSessionPagination(sess, &opts)
123+
}
124+
return comments, sess.Find(&comments)
125+
}
126+
127+
// LoadPoster loads the poster user
128+
func (c *CommitComment) LoadPoster(ctx context.Context) error {
129+
if c.Poster != nil {
130+
return nil
131+
}
132+
var err error
133+
c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID)
134+
if err != nil {
135+
if user_model.IsErrUserNotExist(err) {
136+
c.PosterID = user_model.GhostUserID
137+
c.Poster = user_model.NewGhostUser()
138+
}
139+
}
140+
return err
141+
}
142+
143+
// LoadRepo loads the repository
144+
func (c *CommitComment) LoadRepo(ctx context.Context) error {
145+
if c.Repo != nil {
146+
return nil
147+
}
148+
var err error
149+
c.Repo, err = GetRepositoryByID(ctx, c.RepoID)
150+
return err
151+
}
152+
153+
// LoadAttachments loads attachments
154+
func (c *CommitComment) LoadAttachments(ctx context.Context) error {
155+
if len(c.Attachments) > 0 {
156+
return nil
157+
}
158+
var err error
159+
c.Attachments, err = GetAttachmentsByCommentID(ctx, c.ID)
160+
return err
161+
}
162+
163+
// DiffSide returns "previous" if Line is negative and "proposed" if positive
164+
func (c *CommitComment) DiffSide() string {
165+
if c.Line < 0 {
166+
return "previous"
167+
}
168+
return "proposed"
169+
}
170+
171+
// UnsignedLine returns the absolute value of the line number
172+
func (c *CommitComment) UnsignedLine() uint64 {
173+
if c.Line < 0 {
174+
return uint64(c.Line * -1)
175+
}
176+
return uint64(c.Line)
177+
}
178+
179+
// HashTag returns unique hash tag for comment
180+
func (c *CommitComment) HashTag() string {
181+
return fmt.Sprintf("commitcomment-%d", c.ID)
182+
}
183+
184+
// UpdateCommitComment updates a commit comment
185+
func UpdateCommitComment(ctx context.Context, comment *CommitComment) error {
186+
_, err := db.GetEngine(ctx).ID(comment.ID).AllCols().Update(comment)
187+
return err
188+
}
189+
190+
// DeleteCommitComment deletes a commit comment
191+
func DeleteCommitComment(ctx context.Context, comment *CommitComment) error {
192+
_, err := db.GetEngine(ctx).ID(comment.ID).Delete(comment)
193+
return err
194+
}

routers/web/repo/commit.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"code.gitea.io/gitea/services/gitdiff"
3737
repo_service "code.gitea.io/gitea/services/repository"
3838
"code.gitea.io/gitea/services/repository/gitgraph"
39+
user_service "code.gitea.io/gitea/services/user"
3940
)
4041

4142
const (
@@ -272,6 +273,14 @@ func LoadBranchesAndTags(ctx *context.Context) {
272273
// Diff show different from current commit to previous commit
273274
func Diff(ctx *context.Context) {
274275
ctx.Data["PageIsDiff"] = true
276+
ctx.Data["PageIsCommitDiff"] = true // Enable comment buttons on commit diffs
277+
278+
// Set up user blocking function for comments
279+
ctx.Data["SignedUserID"] = ctx.Doer.ID
280+
ctx.Data["SignedUser"] = ctx.Doer
281+
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
282+
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
283+
}
275284

276285
userName := ctx.Repo.Owner.Name
277286
repoName := ctx.Repo.Repository.Name
@@ -363,6 +372,13 @@ func Diff(ctx *context.Context) {
363372
setCompareContext(ctx, parentCommit, commit, userName, repoName)
364373
ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID)
365374
ctx.Data["Commit"] = commit
375+
376+
// Load commit comments into the diff
377+
if err := loadCommitCommentsIntoDiff(ctx, diff, commitID); err != nil {
378+
ctx.ServerError("loadCommitCommentsIntoDiff", err)
379+
return
380+
}
381+
366382
ctx.Data["Diff"] = diff
367383
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
368384

@@ -427,6 +443,99 @@ func Diff(ctx *context.Context) {
427443
ctx.HTML(http.StatusOK, tplCommitPage)
428444
}
429445

446+
// loadCommitCommentsIntoDiff loads commit comments and attaches them to diff lines
447+
func loadCommitCommentsIntoDiff(ctx *context.Context, diff *gitdiff.Diff, commitSHA string) error {
448+
comments, err := repo_model.FindCommitComments(ctx, repo_model.FindCommitCommentsOptions{
449+
RepoID: ctx.Repo.Repository.ID,
450+
CommitSHA: commitSHA,
451+
})
452+
if err != nil {
453+
return err
454+
}
455+
456+
// Load posters, attachments, reactions, and render comments
457+
for _, comment := range comments {
458+
if err := comment.LoadPoster(ctx); err != nil {
459+
return err
460+
}
461+
if err := comment.LoadAttachments(ctx); err != nil {
462+
return err
463+
}
464+
if err := repo_service.RenderCommitComment(ctx, comment); err != nil {
465+
return err
466+
}
467+
// Load reactions for this comment
468+
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, comment.ID)
469+
if err != nil {
470+
return err
471+
}
472+
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
473+
return err
474+
}
475+
comment.Reactions = reactions
476+
}
477+
478+
// Group comments by file and line number
479+
allComments := make(map[string]map[int64][]*repo_model.CommitComment)
480+
for _, comment := range comments {
481+
if allComments[comment.TreePath] == nil {
482+
allComments[comment.TreePath] = make(map[int64][]*repo_model.CommitComment)
483+
}
484+
allComments[comment.TreePath][comment.Line] = append(allComments[comment.TreePath][comment.Line], comment)
485+
}
486+
487+
// Attach comments to diff lines
488+
for _, file := range diff.Files {
489+
if lineComments, ok := allComments[file.Name]; ok {
490+
for _, section := range file.Sections {
491+
for _, line := range section.Lines {
492+
// Check for comments on the left side (previous/old)
493+
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
494+
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
495+
}
496+
// Check for comments on the right side (proposed/new)
497+
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
498+
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
499+
}
500+
}
501+
}
502+
}
503+
}
504+
505+
return nil
506+
}
507+
508+
// convertCommitCommentsToIssueComments converts CommitComment to Comment interface for template compatibility
509+
func convertCommitCommentsToIssueComments(commitComments []*repo_model.CommitComment) []*issues_model.Comment {
510+
comments := make([]*issues_model.Comment, len(commitComments))
511+
for i, cc := range commitComments {
512+
var reactions issues_model.ReactionList
513+
if cc.Reactions != nil {
514+
if r, ok := cc.Reactions.(issues_model.ReactionList); ok {
515+
reactions = r
516+
}
517+
}
518+
// Create a minimal Comment struct that the template can use
519+
comments[i] = &issues_model.Comment{
520+
ID: cc.ID,
521+
PosterID: cc.PosterID,
522+
Poster: cc.Poster,
523+
OriginalAuthor: cc.OriginalAuthor,
524+
OriginalAuthorID: cc.OriginalAuthorID,
525+
TreePath: cc.TreePath,
526+
Line: cc.Line,
527+
Content: cc.Content,
528+
ContentVersion: cc.ContentVersion,
529+
RenderedContent: cc.RenderedContent,
530+
CreatedUnix: cc.CreatedUnix,
531+
UpdatedUnix: cc.UpdatedUnix,
532+
Reactions: reactions,
533+
Attachments: cc.Attachments,
534+
}
535+
}
536+
return comments
537+
}
538+
430539
// RawDiff dumps diff results of repository in given commit ID to io.Writer
431540
func RawDiff(ctx *context.Context) {
432541
var gitRepo *git.Repository

0 commit comments

Comments
 (0)