From 79eb8ac354c4905ad8e446868e24373d7e67e629 Mon Sep 17 00:00:00 2001 From: brymut Date: Fri, 7 Nov 2025 17:09:48 +0300 Subject: [PATCH] feat(commit_comment): Add inline comments on commit diffs --- models/migrations/migrations.go | 1 + models/migrations/v1_26/v324.go | 29 ++ models/repo/commit_comment.go | 194 ++++++++ routers/web/repo/commit.go | 111 +++++ routers/web/repo/commit_comment.go | 438 ++++++++++++++++++ routers/web/repo/pull_review.go | 1 + routers/web/web.go | 12 + services/forms/repo_form.go | 1 + services/repository/commit_comment.go | 95 ++++ templates/repo/diff/box.tmpl | 2 +- templates/repo/diff/comment_form.tmpl | 7 +- templates/repo/diff/comments.tmpl | 8 +- templates/repo/diff/section_split.tmpl | 8 +- templates/repo/diff/section_unified.tmpl | 2 +- .../repo/issue/view_content/context_menu.tmpl | 12 +- web_src/js/features/repo-issue.ts | 2 +- web_src/js/features/repo-legacy.ts | 11 + 17 files changed, 920 insertions(+), 14 deletions(-) create mode 100644 models/migrations/v1_26/v324.go create mode 100644 models/repo/commit_comment.go create mode 100644 routers/web/repo/commit_comment.go create mode 100644 services/repository/commit_comment.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e8ebb5df43ce1..009db8e449187 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go new file mode 100644 index 0000000000000..bd3cc424e700d --- /dev/null +++ b/models/migrations/v1_26/v324.go @@ -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)) +} diff --git a/models/repo/commit_comment.go b/models/repo/commit_comment.go new file mode 100644 index 0000000000000..2d63918523411 --- /dev/null +++ b/models/repo/commit_comment.go @@ -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 +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 6bb9a8ae770ea..8a87e6a845e2a 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -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 diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go new file mode 100644 index 0000000000000..b7d1aee235cf0 --- /dev/null +++ b/routers/web/repo/commit_comment.go @@ -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 +} diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 18e14e9b224c4..19c2d599b6c2d 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -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 diff --git a/routers/web/web.go b/routers/web/web.go index 8b55e4469eeb7..dc5d977cccbe1 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 6820521ba3de2..aedb2bd7cab53 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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 } diff --git a/services/repository/commit_comment.go b/services/repository/commit_comment.go new file mode 100644 index 0000000000000..9f04a5e79707f --- /dev/null +++ b/services/repository/commit_comment.go @@ -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) +} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index ff9bd2e792ede..1d91aaf06f6e2 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -185,7 +185,7 @@ {{end}} {{else}} - +
{{if $.IsSplitStyle}} {{template "repo/diff/section_split" dict "file" . "root" $}} {{else}} diff --git a/templates/repo/diff/comment_form.tmpl b/templates/repo/diff/comment_form.tmpl index 58b675467c035..96dd72acf8148 100644 --- a/templates/repo/diff/comment_form.tmpl +++ b/templates/repo/diff/comment_form.tmpl @@ -1,8 +1,9 @@ {{if and $.root.SignedUserID (not $.Repository.IsArchived)}} - + {{$.root.CsrfTokenHtml}} + {{if $.root.PageIsCommitDiff}}{{end}} @@ -28,7 +29,9 @@ diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index ab23b1b934b7b..eaef3a1c578f7 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -28,7 +28,7 @@ {{else}}
{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}} @@ -43,7 +43,7 @@ {{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $match.RightIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}} @@ -60,7 +60,7 @@ {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.LeftIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) (not (eq .GetType 2)) -}} @@ -75,7 +75,7 @@ {{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.RightIdx}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) (not (eq .GetType 3)) -}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index 908b14656e36e..a8493d7c7f913 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -33,7 +33,7 @@ {{template "repo/diff/section_code" dict "diff" $inlineDiff}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsCommitDiff) -}} diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 749a2fa0ddc0f..2a304e3d6f55d 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -4,7 +4,9 @@