Skip to content

Commit 5de25c1

Browse files
ktamas77claude
andcommitted
Add inline code comments on commits
This feature enables users to add inline code comments on individual commits, similar to the existing PR code review functionality. Comments can be added, edited, and deleted on any line of a commit's diff. Changes: - Add service layer functions for commit comment CRUD operations - Add router handlers and API routes for commit comments - Add model queries to find commit comments - Update diff templates to show comment UI on commit pages - Load and display commit comments in the Diff() handler - Reuse existing PR comment templates and JavaScript Implements: #4898 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 0a0baeb commit 5de25c1

File tree

7 files changed

+315
-6
lines changed

7 files changed

+315
-6
lines changed

models/issues/comment.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,29 @@ func CountComments(ctx context.Context, opts *FindCommentsOptions) (int64, error
10761076
return sess.Count(&Comment{})
10771077
}
10781078

1079+
// FindCommitComments finds all code comments for a specific commit
1080+
func FindCommitComments(ctx context.Context, repoID int64, commitSHA string) (CommentList, error) {
1081+
comments := make([]*Comment, 0, 10)
1082+
return comments, db.GetEngine(ctx).
1083+
Where("commit_sha = ?", commitSHA).
1084+
And("type = ?", CommentTypeCode).
1085+
Asc("created_unix").
1086+
Asc("id").
1087+
Find(&comments)
1088+
}
1089+
1090+
// FindCommitLineComments finds code comments for a specific file and line in a commit
1091+
func FindCommitLineComments(ctx context.Context, commitSHA string, treePath string) (CommentList, error) {
1092+
comments := make([]*Comment, 0, 10)
1093+
return comments, db.GetEngine(ctx).
1094+
Where("commit_sha = ?", commitSHA).
1095+
And("tree_path = ?", treePath).
1096+
And("type = ?", CommentTypeCode).
1097+
Asc("created_unix").
1098+
Asc("id").
1099+
Find(&comments)
1100+
}
1101+
10791102
// UpdateCommentInvalidate updates comment invalidated column
10801103
func UpdateCommentInvalidate(ctx context.Context, c *Comment) error {
10811104
_, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c)

routers/web/repo/commit.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ import (
3030
"code.gitea.io/gitea/modules/setting"
3131
"code.gitea.io/gitea/modules/templates"
3232
"code.gitea.io/gitea/modules/util"
33+
"code.gitea.io/gitea/modules/web"
3334
asymkey_service "code.gitea.io/gitea/services/asymkey"
3435
"code.gitea.io/gitea/services/context"
36+
"code.gitea.io/gitea/services/context/upload"
37+
"code.gitea.io/gitea/services/forms"
3538
git_service "code.gitea.io/gitea/services/git"
3639
"code.gitea.io/gitea/services/gitdiff"
3740
repo_service "code.gitea.io/gitea/services/repository"
@@ -417,6 +420,25 @@ func Diff(ctx *context.Context) {
417420
ctx.Data["MergedPRIssueNumber"] = pr.Index
418421
}
419422

423+
// Load commit comments for inline display
424+
comments, err := issues_model.FindCommitComments(ctx, ctx.Repo.Repository.ID, commitID)
425+
if err != nil {
426+
log.Error("FindCommitComments: %v", err)
427+
} else {
428+
if err := comments.LoadPosters(ctx); err != nil {
429+
log.Error("LoadPosters: %v", err)
430+
}
431+
if err := comments.LoadAttachments(ctx); err != nil {
432+
log.Error("LoadAttachments: %v", err)
433+
}
434+
ctx.Data["CommitComments"] = comments
435+
}
436+
437+
// Mark this as a commit page to enable comment UI
438+
ctx.Data["PageIsCommit"] = true
439+
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
440+
upload.AddUploadContext(ctx, "comment")
441+
420442
ctx.HTML(http.StatusOK, tplCommitPage)
421443
}
422444

@@ -469,3 +491,135 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m
469491
}
470492
return commits, nil
471493
}
494+
495+
// RenderNewCommitCodeCommentForm renders the form for creating a new commit code comment
496+
func RenderNewCommitCodeCommentForm(ctx *context.Context) {
497+
ctx.Data["PageIsCommit"] = true
498+
ctx.Data["AfterCommitID"] = ctx.PathParam("sha")
499+
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
500+
upload.AddUploadContext(ctx, "comment")
501+
// Use the same template as PR new comments (defined in pull_review.go)
502+
ctx.HTML(http.StatusOK, "repo/diff/new_comment")
503+
}
504+
505+
// CreateCommitCodeComment creates an inline comment on a commit
506+
func CreateCommitCodeComment(ctx *context.Context) {
507+
form := web.GetForm(ctx).(*forms.CodeCommentForm)
508+
commitSHA := ctx.PathParam("sha")
509+
510+
if ctx.Written() {
511+
return
512+
}
513+
514+
if ctx.HasError() {
515+
ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
516+
ctx.Redirect(fmt.Sprintf("%s/commit/%s", ctx.Repo.RepoLink, commitSHA))
517+
return
518+
}
519+
520+
// Convert line to signed line (negative for previous side)
521+
signedLine := form.Line
522+
if form.Side == "previous" {
523+
signedLine *= -1
524+
}
525+
526+
var attachments []string
527+
if setting.Attachment.Enabled {
528+
attachments = form.Files
529+
}
530+
531+
// Create the comment using the service layer
532+
comment, err := repo_service.CreateCommitCodeComment(
533+
ctx,
534+
ctx.Doer,
535+
ctx.Repo.Repository,
536+
ctx.Repo.GitRepo,
537+
commitSHA,
538+
signedLine,
539+
form.Content,
540+
form.TreePath,
541+
attachments,
542+
)
543+
if err != nil {
544+
ctx.ServerError("CreateCommitCodeComment", err)
545+
return
546+
}
547+
548+
log.Trace("Commit comment created: %d for commit %s in %-v", comment.ID, commitSHA, ctx.Repo.Repository)
549+
550+
// Render the comment
551+
ctx.Data["Comment"] = comment
552+
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
553+
upload.AddUploadContext(ctx, "comment")
554+
555+
ctx.JSON(http.StatusOK, map[string]any{
556+
"ok": true,
557+
"comment": comment,
558+
})
559+
}
560+
561+
// UpdateCommitCodeComment updates an existing commit inline comment
562+
func UpdateCommitCodeComment(ctx *context.Context) {
563+
form := web.GetForm(ctx).(*forms.CodeCommentForm)
564+
commentID := ctx.PathParamInt64(":id")
565+
566+
comment, err := issues_model.GetCommentByID(ctx, commentID)
567+
if err != nil {
568+
ctx.ServerError("GetCommentByID", err)
569+
return
570+
}
571+
572+
// Verify this is a commit comment
573+
if comment.Type != issues_model.CommentTypeCode || comment.CommitSHA == "" {
574+
ctx.NotFound(errors.New("not a commit code comment"))
575+
return
576+
}
577+
578+
// Verify the comment belongs to this repository
579+
if comment.PosterID != ctx.Doer.ID {
580+
ctx.HTTPError(http.StatusForbidden)
581+
return
582+
}
583+
584+
var attachments []string
585+
if setting.Attachment.Enabled {
586+
attachments = form.Files
587+
}
588+
589+
// Update the comment
590+
if err := repo_service.UpdateCommitCodeComment(ctx, ctx.Doer, comment, form.Content, attachments); err != nil {
591+
ctx.ServerError("UpdateCommitCodeComment", err)
592+
return
593+
}
594+
595+
ctx.JSON(http.StatusOK, map[string]any{
596+
"ok": true,
597+
})
598+
}
599+
600+
// DeleteCommitCodeComment deletes a commit inline comment
601+
func DeleteCommitCodeComment(ctx *context.Context) {
602+
commentID := ctx.PathParamInt64(":id")
603+
604+
comment, err := issues_model.GetCommentByID(ctx, commentID)
605+
if err != nil {
606+
ctx.ServerError("GetCommentByID", err)
607+
return
608+
}
609+
610+
// Verify this is a commit comment
611+
if comment.Type != issues_model.CommentTypeCode || comment.CommitSHA == "" {
612+
ctx.NotFound(errors.New("not a commit code comment"))
613+
return
614+
}
615+
616+
// Delete the comment
617+
if err := repo_service.DeleteCommitCodeComment(ctx, ctx.Doer, comment); err != nil {
618+
ctx.ServerError("DeleteCommitCodeComment", err)
619+
return
620+
}
621+
622+
ctx.JSON(http.StatusOK, map[string]any{
623+
"ok": true,
624+
})
625+
}

routers/web/web.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,16 @@ func registerWebRoutes(m *web.Router) {
16161616
m.Get("/commit/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff)
16171617
m.Get("/commit/{sha:([a-f0-9]{7,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags)
16181618

1619+
// Commit inline code comments
1620+
m.Group("/commit/{sha:([a-f0-9]{7,64})$}/comments", func() {
1621+
m.Get("/new", reqSignIn, repo.RenderNewCommitCodeCommentForm)
1622+
m.Post("", web.Bind(forms.CodeCommentForm{}), reqSignIn, repo.CreateCommitCodeComment)
1623+
m.Group("/{id}", func() {
1624+
m.Post("", web.Bind(forms.CodeCommentForm{}), reqSignIn, repo.UpdateCommitCodeComment)
1625+
m.Delete("", reqSignIn, repo.DeleteCommitCodeComment)
1626+
})
1627+
})
1628+
16191629
// FIXME: this route `/cherry-pick/{sha}` doesn't seem useful or right, the new code always uses `/_cherrypick/` which could handle branch name correctly
16201630
m.Get("/cherry-pick/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, context.RepoRefByDefaultBranch(), repo.CherryPick)
16211631
}, repo.MustBeNotEmpty)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repository
5+
6+
import (
7+
"context"
8+
9+
issues_model "code.gitea.io/gitea/models/issues"
10+
repo_model "code.gitea.io/gitea/models/repo"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/git"
13+
"code.gitea.io/gitea/modules/log"
14+
"code.gitea.io/gitea/modules/util"
15+
notify_service "code.gitea.io/gitea/services/notify"
16+
)
17+
18+
// CreateCommitCodeComment creates an inline comment on a specific line of a commit
19+
func CreateCommitCodeComment(
20+
ctx context.Context,
21+
doer *user_model.User,
22+
repo *repo_model.Repository,
23+
gitRepo *git.Repository,
24+
commitSHA string,
25+
line int64,
26+
content string,
27+
treePath string,
28+
attachments []string,
29+
) (*issues_model.Comment, error) {
30+
// Validate that the commit exists
31+
commit, err := gitRepo.GetCommit(commitSHA)
32+
if err != nil {
33+
log.Error("GetCommit failed: %v", err)
34+
return nil, err
35+
}
36+
37+
// Create the comment using CreateCommentOptions
38+
comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
39+
Type: issues_model.CommentTypeCode,
40+
Doer: doer,
41+
Repo: repo,
42+
Content: content,
43+
LineNum: line,
44+
TreePath: treePath,
45+
CommitSHA: commit.ID.String(),
46+
Attachments: attachments,
47+
})
48+
if err != nil {
49+
log.Error("CreateComment failed: %v", err)
50+
return nil, err
51+
}
52+
53+
// Load the poster for the comment
54+
if err = comment.LoadPoster(ctx); err != nil {
55+
log.Error("LoadPoster failed: %v", err)
56+
return nil, err
57+
}
58+
59+
// Load attachments
60+
if err = comment.LoadAttachments(ctx); err != nil {
61+
log.Error("LoadAttachments failed: %v", err)
62+
return nil, err
63+
}
64+
65+
// Send notifications for mentions (pass nil for issue since this is a commit comment)
66+
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, nil, doer, comment.Content)
67+
if err != nil {
68+
log.Error("FindAndUpdateIssueMentions failed: %v", err)
69+
}
70+
71+
// Notify about the new commit comment using CreateIssueComment
72+
// (commit comments use the same notification path as issue comments)
73+
notify_service.CreateIssueComment(ctx, doer, repo, nil, comment, mentions)
74+
75+
return comment, nil
76+
}
77+
78+
// UpdateCommitCodeComment updates an existing commit inline comment
79+
func UpdateCommitCodeComment(
80+
ctx context.Context,
81+
doer *user_model.User,
82+
comment *issues_model.Comment,
83+
content string,
84+
attachments []string,
85+
) error {
86+
// Verify the user has permission to edit
87+
if comment.PosterID != doer.ID {
88+
return util.ErrPermissionDenied
89+
}
90+
91+
// Update content
92+
oldContent := comment.Content
93+
comment.Content = content
94+
95+
if err := issues_model.UpdateComment(ctx, comment, comment.ContentVersion, doer); err != nil {
96+
comment.Content = oldContent
97+
return err
98+
}
99+
100+
// Update attachments if provided
101+
if len(attachments) > 0 {
102+
if err := issues_model.UpdateCommentAttachments(ctx, comment, attachments); err != nil {
103+
return err
104+
}
105+
}
106+
107+
return nil
108+
}
109+
110+
// DeleteCommitCodeComment deletes a commit inline comment
111+
func DeleteCommitCodeComment(
112+
ctx context.Context,
113+
doer *user_model.User,
114+
comment *issues_model.Comment,
115+
) error {
116+
// Verify the user has permission to delete
117+
if comment.PosterID != doer.ID {
118+
return util.ErrPermissionDenied
119+
}
120+
121+
return issues_model.DeleteComment(ctx, comment)
122+
}

templates/repo/diff/box.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@
185185
{{end}}
186186
</div>
187187
{{else}}
188-
<table class="chroma" data-new-comment-url="{{$.Issue.Link}}/files/reviews/new_comment" data-path="{{$file.Name}}">
188+
<table class="chroma" data-new-comment-url="{{if $.PageIsCommit}}{{$.RepoLink}}/commit/{{$.CommitID}}/comments/new{{else if $.Issue}}{{$.Issue.Link}}/files/reviews/new_comment{{end}}" data-path="{{$file.Name}}">
189189
{{if $.IsSplitStyle}}
190190
{{template "repo/diff/section_split" dict "file" . "root" $}}
191191
{{else}}

templates/repo/diff/section_split.tmpl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<td class="lines-escape del-code lines-escape-old">{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $leftDiff}}"></button>{{end}}{{end}}</td>
4747
<td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
4848
<td class="lines-code lines-code-old del-code">
49-
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
49+
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommit) -}}
5050
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
5151
{{- svg "octicon-plus" -}}
5252
</button>
@@ -61,7 +61,7 @@
6161
<td class="lines-escape add-code lines-escape-new">{{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $rightDiff}}"></button>{{end}}{{end}}</td>
6262
<td class="lines-type-marker lines-type-marker-new add-code">{{if $match.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$match.GetLineTypeMarker}}"></span>{{end}}</td>
6363
<td class="lines-code lines-code-new add-code">
64-
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
64+
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommit) -}}
6565
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $match.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$match.RightIdx}}">
6666
{{- svg "octicon-plus" -}}
6767
</button>
@@ -78,7 +78,7 @@
7878
<td class="lines-escape lines-escape-old">{{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
7979
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
8080
<td class="lines-code lines-code-old">
81-
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}}
81+
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommit) (not (eq .GetType 2)) -}}
8282
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
8383
{{- svg "octicon-plus" -}}
8484
</button>
@@ -93,7 +93,7 @@
9393
<td class="lines-escape lines-escape-new">{{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
9494
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
9595
<td class="lines-code lines-code-new">
96-
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}}
96+
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommit) (not (eq .GetType 3)) -}}
9797
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$line.RightIdx}}">
9898
{{- svg "octicon-plus" -}}
9999
</button>

templates/repo/diff/section_unified.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
<td class="chroma lines-code blob-hunk">{{template "repo/diff/section_code" dict "diff" $inlineDiff}}</td>
5454
{{else}}
5555
<td class="chroma lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">
56-
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
56+
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommit) -}}
5757
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">
5858
{{- svg "octicon-plus" -}}
5959
</button>

0 commit comments

Comments
 (0)