pull/35896/merge
Bryan Mutai 2025-11-18 17:53:35 +08:00 committed by GitHub
commit 030ade8924
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 920 additions and 14 deletions

View File

@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Add commit comment table", v1_26.AddCommitCommentTable),
}
return preparedMigrations
}

View File

@ -0,0 +1,29 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func AddCommitCommentTable(x *xorm.Engine) error {
type CommitComment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
TreePath string `xorm:"VARCHAR(4000)"`
Line int64
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
PosterID int64 `xorm:"INDEX"`
OriginalAuthor string
OriginalAuthorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
return x.Sync2(new(CommitComment))
}

View File

@ -0,0 +1,194 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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"
"xorm.io/builder"
)
// CommitComment represents a comment on a specific line in a commit diff
type CommitComment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Repo *Repository `xorm:"-"`
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
TreePath string `xorm:"VARCHAR(4000)"` // File path (same field name as issue comments)
Line int64 // - previous line / + proposed line
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
RenderedContent template.HTML `xorm:"-"`
PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"`
OriginalAuthor string
OriginalAuthorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
Attachments []*Attachment `xorm:"-"`
// Fields for template compatibility with PR comments
Review any `xorm:"-"` // Always nil for commit comments
Invalidated bool `xorm:"-"` // Always false for commit comments
ResolveDoer any `xorm:"-"` // Always nil for commit comments
Reactions any `xorm:"-"` // Reactions for this comment
}
// IsResolved returns false (commit comments don't support resolution)
func (c *CommitComment) IsResolved() bool {
return false
}
// HasOriginalAuthor returns if a comment was migrated and has an original author
func (c *CommitComment) HasOriginalAuthor() bool {
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
}
func init() {
db.RegisterModel(new(CommitComment))
}
// ErrCommitCommentNotExist represents a "CommitCommentNotExist" kind of error.
type ErrCommitCommentNotExist struct {
ID int64
}
// IsErrCommitCommentNotExist checks if an error is a ErrCommitCommentNotExist.
func IsErrCommitCommentNotExist(err error) bool {
_, ok := err.(ErrCommitCommentNotExist)
return ok
}
func (err ErrCommitCommentNotExist) Error() string {
return fmt.Sprintf("commit comment does not exist [id: %d]", err.ID)
}
// CreateCommitComment creates a new commit comment
func CreateCommitComment(ctx context.Context, comment *CommitComment) error {
return db.Insert(ctx, comment)
}
// GetCommitCommentByID returns a commit comment by ID
func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) {
comment := new(CommitComment)
has, err := db.GetEngine(ctx).ID(id).Get(comment)
if err != nil {
return nil, err
} else if !has {
return nil, ErrCommitCommentNotExist{id}
}
return comment, nil
}
// FindCommitCommentsOptions describes the conditions to find commit comments
type FindCommitCommentsOptions struct {
db.ListOptions
RepoID int64
CommitSHA string
Path string
Line int64
}
// ToConds implements FindOptions interface
func (opts FindCommitCommentsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
}
if opts.Path != "" {
cond = cond.And(builder.Eq{"tree_path": opts.Path})
}
if opts.Line != 0 {
cond = cond.And(builder.Eq{"line": opts.Line})
}
return cond
}
// FindCommitComments returns commit comments based on options
func FindCommitComments(ctx context.Context, opts FindCommitCommentsOptions) ([]*CommitComment, error) {
comments := make([]*CommitComment, 0, 10)
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, &opts)
}
return comments, sess.Find(&comments)
}
// LoadPoster loads the poster user
func (c *CommitComment) LoadPoster(ctx context.Context) error {
if c.Poster != nil {
return nil
}
var err error
c.Poster, 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 err
}
// LoadRepo loads the repository
func (c *CommitComment) LoadRepo(ctx context.Context) error {
if c.Repo != nil {
return nil
}
var err error
c.Repo, err = GetRepositoryByID(ctx, c.RepoID)
return err
}
// LoadAttachments loads attachments
func (c *CommitComment) LoadAttachments(ctx context.Context) error {
if len(c.Attachments) > 0 {
return nil
}
var err error
c.Attachments, err = GetAttachmentsByCommentID(ctx, c.ID)
return err
}
// DiffSide returns "previous" if Line is negative and "proposed" if positive
func (c *CommitComment) DiffSide() string {
if c.Line < 0 {
return "previous"
}
return "proposed"
}
// UnsignedLine returns the absolute value of the line number
func (c *CommitComment) UnsignedLine() uint64 {
if c.Line < 0 {
return uint64(c.Line * -1)
}
return uint64(c.Line)
}
// HashTag returns unique hash tag for comment
func (c *CommitComment) HashTag() string {
return fmt.Sprintf("commitcomment-%d", c.ID)
}
// UpdateCommitComment updates a commit comment
func UpdateCommitComment(ctx context.Context, comment *CommitComment) error {
_, err := db.GetEngine(ctx).ID(comment.ID).AllCols().Update(comment)
return err
}
// DeleteCommitComment deletes a commit comment
func DeleteCommitComment(ctx context.Context, comment *CommitComment) error {
_, err := db.GetEngine(ctx).ID(comment.ID).Delete(comment)
return err
}

View File

@ -36,6 +36,7 @@ import (
"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 (
@ -272,6 +273,16 @@ func LoadBranchesAndTags(ctx *context.Context) {
// Diff show different from current commit to previous commit
func Diff(ctx *context.Context) {
ctx.Data["PageIsDiff"] = true
ctx.Data["PageIsCommitDiff"] = true // Enable comment buttons on commit diffs
// Set up user blocking function for comments (only if signed in)
if ctx.IsSigned {
ctx.Data["SignedUserID"] = ctx.Doer.ID
ctx.Data["SignedUser"] = ctx.Doer
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
}
userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name
@ -363,6 +374,13 @@ func Diff(ctx *context.Context) {
setCompareContext(ctx, parentCommit, commit, userName, repoName)
ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID)
ctx.Data["Commit"] = commit
// Load commit comments into the diff
if err := loadCommitCommentsIntoDiff(ctx, diff, commitID); err != nil {
ctx.ServerError("loadCommitCommentsIntoDiff", err)
return
}
ctx.Data["Diff"] = diff
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
@ -427,6 +445,99 @@ func Diff(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplCommitPage)
}
// loadCommitCommentsIntoDiff loads commit comments and attaches them to diff lines
func loadCommitCommentsIntoDiff(ctx *context.Context, diff *gitdiff.Diff, commitSHA string) error {
comments, err := repo_model.FindCommitComments(ctx, repo_model.FindCommitCommentsOptions{
RepoID: ctx.Repo.Repository.ID,
CommitSHA: commitSHA,
})
if err != nil {
return err
}
// Load posters, attachments, reactions, and render comments
for _, comment := range comments {
if err := comment.LoadPoster(ctx); err != nil {
return err
}
if err := comment.LoadAttachments(ctx); err != nil {
return err
}
if err := repo_service.RenderCommitComment(ctx, comment); err != nil {
return err
}
// Load reactions for this comment
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, comment.ID)
if err != nil {
return err
}
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
return err
}
comment.Reactions = reactions
}
// Group comments by file and line number
allComments := make(map[string]map[int64][]*repo_model.CommitComment)
for _, comment := range comments {
if allComments[comment.TreePath] == nil {
allComments[comment.TreePath] = make(map[int64][]*repo_model.CommitComment)
}
allComments[comment.TreePath][comment.Line] = append(allComments[comment.TreePath][comment.Line], comment)
}
// Attach comments to diff lines
for _, file := range diff.Files {
if lineComments, ok := allComments[file.Name]; ok {
for _, section := range file.Sections {
for _, line := range section.Lines {
// Check for comments on the left side (previous/old)
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
}
// Check for comments on the right side (proposed/new)
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
}
}
}
}
}
return nil
}
// convertCommitCommentsToIssueComments converts CommitComment to Comment interface for template compatibility
func convertCommitCommentsToIssueComments(commitComments []*repo_model.CommitComment) []*issues_model.Comment {
comments := make([]*issues_model.Comment, len(commitComments))
for i, cc := range commitComments {
var reactions issues_model.ReactionList
if cc.Reactions != nil {
if r, ok := cc.Reactions.(issues_model.ReactionList); ok {
reactions = r
}
}
// Create a minimal Comment struct that the template can use
comments[i] = &issues_model.Comment{
ID: cc.ID,
PosterID: cc.PosterID,
Poster: cc.Poster,
OriginalAuthor: cc.OriginalAuthor,
OriginalAuthorID: cc.OriginalAuthorID,
TreePath: cc.TreePath,
Line: cc.Line,
Content: cc.Content,
ContentVersion: cc.ContentVersion,
RenderedContent: cc.RenderedContent,
CreatedUnix: cc.CreatedUnix,
UpdatedUnix: cc.UpdatedUnix,
Reactions: reactions,
Attachments: cc.Attachments,
}
}
return comments
}
// RawDiff dumps diff results of repository in given commit ID to io.Writer
func RawDiff(ctx *context.Context) {
var gitRepo *git.Repository

View File

@ -0,0 +1,438 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"html/template"
"net/http"
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/forms"
repo_service "code.gitea.io/gitea/services/repository"
user_service "code.gitea.io/gitea/services/user"
)
// RenderNewCommitCodeCommentForm renders the form for creating a new commit code comment
func RenderNewCommitCodeCommentForm(ctx *context.Context) {
commitSHA := ctx.PathParam("sha")
ctx.Data["PageIsCommitDiff"] = true
ctx.Data["AfterCommitID"] = commitSHA
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.HTML(http.StatusOK, tplNewComment)
}
// CreateCommitComment creates a new comment on a commit diff line
func CreateCommitComment(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CodeCommentForm)
if ctx.Written() {
return
}
if !ctx.Repo.CanWriteIssuesOrPulls(false) {
ctx.HTTPError(http.StatusForbidden)
return
}
if form.Content == "" {
log.Warn("Empty comment content")
ctx.HTTPError(http.StatusBadRequest, "EmptyCommentContent")
return
}
signedLine := form.Line
if form.Side == "previous" {
signedLine *= -1
}
var attachments []string
if setting.Attachment.Enabled {
attachments = form.Files
}
_, err := repo_service.CreateCommitComment(ctx, &repo_service.CreateCommitCommentOptions{
Repo: ctx.Repo.Repository,
Doer: ctx.Doer,
CommitSHA: form.CommitSHA,
Path: form.TreePath,
Line: signedLine,
Content: form.Content,
Attachments: attachments,
})
if err != nil {
ctx.ServerError("CreateCommitComment", err)
return
}
// Fetch all comments for this line to show the full conversation
allComments, err := repo_model.FindCommitComments(ctx, repo_model.FindCommitCommentsOptions{
RepoID: ctx.Repo.Repository.ID,
CommitSHA: form.CommitSHA,
Path: form.TreePath,
Line: signedLine,
})
if err != nil {
ctx.ServerError("FindCommitComments", err)
return
}
// Load and render all comments
issueComments := make([]*issues_model.Comment, 0, len(allComments))
for _, cc := range allComments {
if err := cc.LoadPoster(ctx); err != nil {
ctx.ServerError("LoadPoster", err)
return
}
if err := cc.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
if err := repo_service.RenderCommitComment(ctx, cc); err != nil {
ctx.ServerError("RenderCommitComment", err)
return
}
// Load reactions for this comment
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, cc.ID)
if err != nil {
ctx.ServerError("FindCommentReactions", err)
return
}
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("LoadUsers", err)
return
}
cc.Reactions = reactions
issueComments = append(issueComments, convertCommitCommentToIssueComment(cc))
}
// Prepare data for template
ctx.Data["comments"] = issueComments
ctx.Data["SignedUserID"] = ctx.Doer.ID
ctx.Data["SignedUser"] = ctx.Doer
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
ctx.Data["PageIsCommitDiff"] = true
ctx.Data["AfterCommitID"] = form.CommitSHA
ctx.HTML(http.StatusOK, tplDiffConversation)
}
// LoadCommitComments loads comments for a commit diff
func LoadCommitComments(ctx *context.Context) {
commitSHA := ctx.PathParam("sha")
if commitSHA == "" {
ctx.HTTPError(http.StatusBadRequest, "Missing commit SHA")
return
}
comments, err := repo_model.FindCommitComments(ctx, repo_model.FindCommitCommentsOptions{
RepoID: ctx.Repo.Repository.ID,
CommitSHA: commitSHA,
})
if err != nil {
ctx.ServerError("FindCommitComments", err)
return
}
// Load posters, attachments, and render comments
for _, comment := range comments {
if err := comment.LoadPoster(ctx); err != nil {
ctx.ServerError("LoadPoster", err)
return
}
if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
if err := repo_service.RenderCommitComment(ctx, comment); err != nil {
ctx.ServerError("RenderCommitComment", err)
return
}
// Load reactions for this comment
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, comment.ID)
if err != nil {
ctx.ServerError("FindCommentReactions", err)
return
}
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("LoadUsers", err)
return
}
comment.Reactions = reactions
}
// Group comments by file and line
commentMap := make(map[string]map[string][]*repo_model.CommitComment)
for _, comment := range comments {
if commentMap[comment.TreePath] == nil {
commentMap[comment.TreePath] = make(map[string][]*repo_model.CommitComment)
}
key := comment.DiffSide() + "_" + strconv.FormatUint(comment.UnsignedLine(), 10)
commentMap[comment.TreePath][key] = append(commentMap[comment.TreePath][key], comment)
}
ctx.Data["CommitComments"] = commentMap
ctx.Data["SignedUserID"] = ctx.Doer.ID
ctx.Data["SignedUser"] = ctx.Doer
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
ctx.Data["IsCommitComment"] = true
ctx.Data["AfterCommitID"] = commitSHA
ctx.JSON(http.StatusOK, map[string]any{
"ok": true,
"comments": commentMap,
})
}
// UpdateCommitCommentContent updates the content of a commit comment
func UpdateCommitCommentContent(ctx *context.Context) {
comment, err := repo_model.GetCommitCommentByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if repo_model.IsErrCommitCommentNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetCommitCommentByID", err)
}
return
}
if comment.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(errors.New("repo ID mismatch"))
return
}
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(false)) {
ctx.HTTPError(http.StatusForbidden)
return
}
newContent := ctx.FormString("content")
contentVersion := ctx.FormInt("content_version")
if contentVersion != comment.ContentVersion {
ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed"))
return
}
if newContent != comment.Content {
oldContent := comment.Content
comment.Content = newContent
if err = repo_service.UpdateCommitComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil {
ctx.ServerError("UpdateCommitComment", err)
return
}
}
if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
// when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates
if !ctx.FormBool("ignore_attachments") {
if err := updateCommitCommentAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil {
ctx.ServerError("UpdateAttachments", err)
return
}
}
ctx.JSON(http.StatusOK, map[string]any{
"content": string(comment.RenderedContent),
"contentVersion": comment.ContentVersion,
"attachments": renderCommitCommentAttachments(ctx, comment.Attachments, comment.Content),
})
}
// updateCommitCommentAttachments updates attachments for a commit comment
func updateCommitCommentAttachments(ctx *context.Context, comment *repo_model.CommitComment, uuids []string) error {
if len(uuids) == 0 {
return nil
}
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := range attachments {
attachments[i].CommentID = comment.ID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
}
}
comment.Attachments = attachments
return nil
}
// convertCommitCommentToIssueComment converts a single CommitComment to Comment for template compatibility
func convertCommitCommentToIssueComment(cc *repo_model.CommitComment) *issues_model.Comment {
var reactions issues_model.ReactionList
if cc.Reactions != nil {
if r, ok := cc.Reactions.(issues_model.ReactionList); ok {
reactions = r
}
}
return &issues_model.Comment{
ID: cc.ID,
PosterID: cc.PosterID,
Poster: cc.Poster,
OriginalAuthor: cc.OriginalAuthor,
OriginalAuthorID: cc.OriginalAuthorID,
TreePath: cc.TreePath,
Line: cc.Line,
Content: cc.Content,
ContentVersion: cc.ContentVersion,
RenderedContent: cc.RenderedContent,
CreatedUnix: cc.CreatedUnix,
UpdatedUnix: cc.UpdatedUnix,
Reactions: reactions,
Attachments: cc.Attachments,
}
}
// DeleteCommitComment deletes a commit comment
func DeleteCommitComment(ctx *context.Context) {
comment, err := repo_model.GetCommitCommentByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if repo_model.IsErrCommitCommentNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetCommitCommentByID", err)
}
return
}
if comment.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(errors.New("repo ID mismatch"))
return
}
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(false)) {
ctx.HTTPError(http.StatusForbidden)
return
}
if err = repo_model.DeleteCommitComment(ctx, comment); err != nil {
ctx.ServerError("DeleteCommitComment", err)
return
}
ctx.Status(http.StatusOK)
}
// ChangeCommitCommentReaction creates or removes a reaction for a commit comment
func ChangeCommitCommentReaction(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.ReactionForm)
comment, err := repo_model.GetCommitCommentByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if repo_model.IsErrCommitCommentNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetCommitCommentByID", err)
}
return
}
if comment.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(errors.New("repo ID mismatch"))
return
}
if !ctx.IsSigned {
ctx.HTTPError(http.StatusForbidden)
return
}
switch ctx.PathParam("action") {
case "react":
// Create reaction using IssueID=0 for commit comments
reaction, err := issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
Type: form.Content,
DoerID: ctx.Doer.ID,
IssueID: 0, // Use 0 for commit comments
CommentID: comment.ID,
})
if err != nil {
if issues_model.IsErrForbiddenIssueReaction(err) {
ctx.ServerError("ChangeCommitCommentReaction", err)
return
}
log.Info("CreateReaction: %s", err)
break
}
log.Trace("Reaction for commit comment created: %d/%d/%d", ctx.Repo.Repository.ID, comment.ID, reaction.ID)
case "unreact":
if err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, 0, comment.ID, form.Content); err != nil {
ctx.ServerError("DeleteCommentReaction", err)
return
}
log.Trace("Reaction for commit comment removed: %d/%d", ctx.Repo.Repository.ID, comment.ID)
default:
ctx.NotFound(nil)
return
}
// Reload reactions
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, comment.ID)
if err != nil {
log.Info("FindCommentReactions: %s", err)
}
// Load reaction users
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
log.Info("LoadUsers: %s", err)
}
if len(reactions) == 0 {
ctx.JSON(http.StatusOK, map[string]any{
"empty": true,
"html": "",
})
return
}
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
"ActionURL": fmt.Sprintf("%s/commit-comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
"Reactions": reactions.GroupByType(),
})
if err != nil {
ctx.ServerError("ChangeCommitCommentReaction.HTMLString", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"html": html,
})
}
// renderCommitCommentAttachments renders attachments HTML for commit comments
func renderCommitCommentAttachments(ctx *context.Context, attachments []*repo_model.Attachment, content string) template.HTML {
attachHTML, err := ctx.RenderToHTML(templates.TplName("repo/issue/view_content/attachments"), map[string]any{
"ctxData": ctx.Data,
"Attachments": attachments,
"Content": content,
})
if err != nil {
ctx.ServerError("renderCommitCommentAttachments.RenderToHTML", err)
return ""
}
return attachHTML
}

View File

@ -30,6 +30,7 @@ const (
tplConversationOutdated templates.TplName = "repo/diff/conversation_outdated"
tplTimelineConversation templates.TplName = "repo/issue/view_content/conversation"
tplNewComment templates.TplName = "repo/diff/new_comment"
tplCommitConversation templates.TplName = "repo/diff/commit_conversation"
)
// RenderNewCodeCommentForm will render the form for creating a new review comment

View File

@ -1622,6 +1622,18 @@ func registerWebRoutes(m *web.Router) {
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.Combo("/code-comment/new").Get(repo.RenderNewCommitCodeCommentForm).
Post(web.Bind(forms.CodeCommentForm{}), repo.CreateCommitComment)
m.Get("/comments", repo.LoadCommitComments)
}, reqSignIn, context.RepoMustNotBeArchived())
// Commit comment editing, deletion, and reactions
m.Group("/commit-comments/{id}", func() {
m.Post("", repo.UpdateCommitCommentContent)
m.Post("/delete", repo.DeleteCommitComment)
m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeCommitCommentReaction)
}, reqSignIn, context.RepoMustNotBeArchived())
// 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)

View File

@ -554,6 +554,7 @@ type CodeCommentForm struct {
SingleReview bool `form:"single_review"`
Reply int64 `form:"reply"`
LatestCommitID string
CommitSHA string // For commit comments (non-PR)
Files []string
}

View File

@ -0,0 +1,95 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"context"
"errors"
"fmt"
"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/markup/markdown"
)
// CreateCommitCommentOptions holds options for creating a commit comment
type CreateCommitCommentOptions struct {
Repo *repo_model.Repository
Doer *user_model.User
CommitSHA string
Path string
Line int64
Content string
Attachments []string
}
// CreateCommitComment creates a new comment on a commit diff line
func CreateCommitComment(ctx context.Context, opts *CreateCommitCommentOptions) (*repo_model.CommitComment, error) {
comment := &repo_model.CommitComment{
RepoID: opts.Repo.ID,
CommitSHA: opts.CommitSHA,
TreePath: opts.Path,
Line: opts.Line,
Content: opts.Content,
PosterID: opts.Doer.ID,
}
if err := repo_model.CreateCommitComment(ctx, comment); err != nil {
return nil, err
}
// Handle attachments
if len(opts.Attachments) > 0 {
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
if err != nil {
return nil, fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err)
}
for i := range attachments {
attachments[i].CommentID = comment.ID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
return nil, fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
}
}
comment.Attachments = attachments
}
// Load poster for rendering
if err := comment.LoadPoster(ctx); err != nil {
return nil, err
}
return comment, nil
}
// RenderCommitComment renders the comment content as markdown
func RenderCommitComment(ctx context.Context, comment *repo_model.CommitComment) error {
if err := comment.LoadRepo(ctx); err != nil {
return err
}
rctx := renderhelper.NewRenderContextRepoComment(ctx, comment.Repo)
rendered, err := markdown.RenderString(rctx, comment.Content)
if err != nil {
return err
}
comment.RenderedContent = rendered
return nil
}
// UpdateCommitComment updates a commit comment
func UpdateCommitComment(ctx context.Context, comment *repo_model.CommitComment, contentVersion int, doer *user_model.User, oldContent string) error {
if contentVersion != comment.ContentVersion {
return errors.New("content version mismatch")
}
comment.ContentVersion++
if err := repo_model.UpdateCommitComment(ctx, comment); err != nil {
return err
}
// Re-render the comment
return RenderCommitComment(ctx, comment)
}

View File

@ -185,7 +185,7 @@
{{end}}
</div>
{{else}}
<table class="chroma" data-new-comment-url="{{$.Issue.Link}}/files/reviews/new_comment" data-path="{{$file.Name}}">
<table class="chroma" data-new-comment-url="{{if $.PageIsCommitDiff}}{{$.RepoLink}}/commit/{{$.AfterCommitID}}/code-comment/new{{else}}{{$.Issue.Link}}/files/reviews/new_comment{{end}}" data-path="{{$file.Name}}">
{{if $.IsSplitStyle}}
{{template "repo/diff/section_split" dict "file" . "root" $}}
{{else}}

View File

@ -1,8 +1,9 @@
{{if and $.root.SignedUserID (not $.Repository.IsArchived)}}
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{$.root.Issue.Link}}/files/reviews/comments" method="post">
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{if $.root.PageIsCommitDiff}}{{$.root.RepoLink}}/commit/{{$.root.AfterCommitID}}/code-comment/new{{else}}{{$.root.Issue.Link}}/files/reviews/comments{{end}}" method="post">
{{$.root.CsrfTokenHtml}}
<input type="hidden" name="origin" value="{{if $.root.PageIsPullFiles}}diff{{else}}timeline{{end}}">
<input type="hidden" name="latest_commit_id" value="{{$.root.AfterCommitID}}">
{{if $.root.PageIsCommitDiff}}<input type="hidden" name="commit_sha" value="{{$.root.AfterCommitID}}">{{end}}
<input type="hidden" name="side" value="{{if $.Side}}{{$.Side}}{{end}}">
<input type="hidden" name="line" value="{{if $.Line}}{{$.Line}}{{end}}">
<input type="hidden" name="path" value="{{if $.File}}{{$.File}}{{end}}">
@ -28,7 +29,9 @@
<div class="field footer">
<div class="flex-text-block tw-justify-end">
{{if $.reply}}
{{if $.root.PageIsCommitDiff}}
<button type="submit" class="ui submit primary tiny button btn-add-single">{{ctx.Locale.Tr "repo.diff.comment.add_single_comment"}}</button>
{{else if $.reply}}
<button class="ui submit primary tiny button btn-reply" type="submit">{{ctx.Locale.Tr "repo.diff.comment.reply"}}</button>
<input type="hidden" name="reply" value="{{$.reply}}">
<input type="hidden" name="single_review" value="true">

View File

@ -1,6 +1,8 @@
{{range .comments}}
{{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
{{$commentPath := "comments"}}
{{if $.root.PageIsCommitDiff}}{{$commentPath = "commit-comments"}}{{end}}
<div class="comment" id="{{.HashTag}}">
<div class="tw-mt-2 tw-mr-4">
{{if .OriginalAuthor}}
@ -51,7 +53,7 @@
{{end}}
{{end}}
{{if not $.root.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/%s/%d/reactions" $.root.RepoLink $commentPath .ID)}}
{{end}}
{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
</div>
@ -65,14 +67,14 @@
{{end}}
</div>
<div id="issuecomment-{{.ID}}-raw" class="raw-content tw-hidden">{{.Content}}</div>
<div class="edit-content-zone tw-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-content-version="{{.ContentVersion}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{$.root.RepoLink}}/comments/{{.ID}}/attachments"></div>
<div class="edit-content-zone tw-hidden" data-update-url="{{$.root.RepoLink}}/{{if $.root.PageIsCommitDiff}}commit-comments{{else}}comments{{end}}/{{.ID}}" data-content-version="{{.ContentVersion}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{$.root.RepoLink}}/{{if $.root.PageIsCommitDiff}}commit-comments{{else}}comments{{end}}/{{.ID}}/attachments"></div>
{{if .Attachments}}
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
{{end}}
</div>
{{$reactions := .Reactions.GroupByType}}
{{if $reactions}}
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID) "Reactions" $reactions}}
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/%s/%d/reactions" $.root.RepoLink $commentPath .ID) "Reactions" $reactions}}
{{end}}
</div>
</div>

View File

@ -28,7 +28,7 @@
<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>
<td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
<td class="lines-code lines-code-old del-code">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}}
<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}}">
{{- svg "octicon-plus" -}}
</button>
@ -43,7 +43,7 @@
<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>
<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>
<td class="lines-code lines-code-new add-code">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}}
<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}}">
{{- svg "octicon-plus" -}}
</button>
@ -60,7 +60,7 @@
<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>
<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>
<td class="lines-code lines-code-old">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}}
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) (not (eq .GetType 2)) -}}
<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}}">
{{- svg "octicon-plus" -}}
</button>
@ -75,7 +75,7 @@
<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>
<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>
<td class="lines-code lines-code-new">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}}
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) (not (eq .GetType 3)) -}}
<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}}">
{{- svg "octicon-plus" -}}
</button>

View File

@ -33,7 +33,7 @@
<td class="chroma lines-code blob-hunk">{{template "repo/diff/section_code" dict "diff" $inlineDiff}}</td>
{{else}}
<td class="chroma lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}}
{{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}}
<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}}">
{{- svg "octicon-plus" -}}
</button>

View File

@ -4,7 +4,9 @@
</a>
<div class="menu">
{{$referenceUrl := ""}}
{{if .issue}}
{{if ctx.RootData.PageIsCommitDiff}}
{{$referenceUrl = printf "%s/commit/%s#%s" ctx.RootData.RepoLink ctx.RootData.AfterCommitID .item.HashTag}}
{{else if .issue}}
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{else}}
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
@ -22,7 +24,13 @@
<div class="divider"></div>
<div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
{{if .delete}}
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{ctx.RootData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
{{$deleteURL := ""}}
{{if ctx.RootData.PageIsCommitDiff}}
{{$deleteURL = printf "%s/commit-comments/%d/delete" ctx.RootData.RepoLink .item.ID}}
{{else}}
{{$deleteURL = printf "%s/comments/%d/delete" ctx.RootData.RepoLink .item.ID}}
{{end}}
<div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{$deleteURL}}" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
{{end}}
{{end}}
{{end}}

View File

@ -303,7 +303,7 @@ export function initRepoPullRequestReview() {
});
// The following part is only for diff views
if (!document.querySelector('.repository.pull.diff')) return;
if (!document.querySelector('.repository.pull.diff, .repository.diff')) return;
const elReviewBtn = document.querySelector('.js-btn-review');
const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');

View File

@ -4,6 +4,7 @@ import {
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueReferenceIssue,
initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
initRepoPullRequestReview,
} from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoCloneButtons} from './repo-common.ts';
@ -43,6 +44,7 @@ export function initRepository() {
initRepoBranchTagSelector();
initRepoCommentFormAndSidebar();
initRepoPullRequestReview(); // This handles code comment buttons on diffs
// Labels
initCompLabelEdit('.page-content.repository.labels');
@ -72,5 +74,14 @@ export function initRepository() {
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
}
// Commit diff - enable code comments
if (pageContent.matches('.page-content.repository.diff')) {
initRepoIssueCommentEdit();
initRepoIssueCommentDelete();
initRepoIssueReferenceIssue();
initRepoIssueCodeCommentCancel();
initCompReactionSelector();
}
initUnicodeEscapeButton();
}