From b9c6802d091d30da2f570c686427a9c58188339a Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 8 Mar 2026 14:55:29 +0530 Subject: [PATCH 01/13] feat: add inline comments on commit diffs Add a new commit_comment table and full CRUD flow to support inline comments on commit diff views, similar to PR review comments but standalone (no issue/PR required). Changes: - New CommitComment model with migration (v326) - Web handlers for rendering form, creating, and deleting comments - Diff context patch generation for comment positioning - Templates for commit comment conversation, individual comments, form - Modified diff section templates to render existing commit comments - Reuses existing JS for add-code-comment and delete-comment flows Closes #4898 --- models/migrations/v1_26/v326.go | 219 ++----------------- models/repo/commit_comment.go | 155 +++++++++++++ routers/web/repo/commit.go | 30 +++ routers/web/repo/commit_comment.go | 156 +++++++++++++ routers/web/web.go | 3 + templates/repo/diff/box.tmpl | 2 +- templates/repo/diff/commit_comment_form.tmpl | 25 +++ templates/repo/diff/commit_comments.tmpl | 35 +++ templates/repo/diff/commit_conversation.tmpl | 25 +++ templates/repo/diff/new_commit_comment.tmpl | 5 + templates/repo/diff/section_split.tmpl | 37 +++- templates/repo/diff/section_unified.tmpl | 24 +- 12 files changed, 506 insertions(+), 210 deletions(-) create mode 100644 models/repo/commit_comment.go create mode 100644 routers/web/repo/commit_comment.go create mode 100644 templates/repo/diff/commit_comment_form.tmpl create mode 100644 templates/repo/diff/commit_comments.tmpl create mode 100644 templates/repo/diff/commit_conversation.tmpl create mode 100644 templates/repo/diff/new_commit_comment.tmpl diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 1ec0af76a0..387a183bbc 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -4,213 +4,24 @@ package v1_26 import ( - "fmt" - "net/url" - "strconv" - "strings" - - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "xorm.io/xorm" ) -const actionsRunPath = "/actions/runs/" +func AddCommitCommentTable(x *xorm.Engine) error { + type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + TreePath string `xorm:"VARCHAR(4000) NOT NULL"` + Line int64 `xorm:"NOT NULL"` + PosterID int64 `xorm:"INDEX NOT NULL"` + Content string `xorm:"LONGTEXT NOT NULL"` + Patch string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } -type migrationRepository struct { - ID int64 - OwnerName string - Name string -} - -type migrationActionRun struct { - ID int64 - RepoID int64 - Index int64 -} - -type migrationActionRunJob struct { - ID int64 - RunID int64 -} - -type migrationCommitStatus struct { - ID int64 - RepoID int64 - TargetURL string -} - -func FixCommitStatusTargetURLToUseRunAndJobID(x *xorm.Engine) error { - runByIndexCache := make(map[int64]map[int64]*migrationActionRun) - jobsByRunIDCache := make(map[int64][]int64) - repoLinkCache := make(map[int64]string) - - if err := migrateCommitStatusTargetURL(x, "commit_status", runByIndexCache, jobsByRunIDCache, repoLinkCache); err != nil { - return err - } - return migrateCommitStatusTargetURL(x, "commit_status_summary", runByIndexCache, jobsByRunIDCache, repoLinkCache) -} - -func migrateCommitStatusTargetURL( - x *xorm.Engine, - table string, - runByIndexCache map[int64]map[int64]*migrationActionRun, - jobsByRunIDCache map[int64][]int64, - repoLinkCache map[int64]string, -) error { - const batchSize = 500 - var lastID int64 - - for { - var rows []migrationCommitStatus - sess := x.Table(table). - Where("target_url LIKE ?", "%"+actionsRunPath+"%"). - And("id > ?", lastID). - Asc("id"). - Limit(batchSize) - if err := sess.Find(&rows); err != nil { - return fmt.Errorf("query %s: %w", table, err) - } - if len(rows) == 0 { - return nil - } - - for _, row := range rows { - lastID = row.ID - if row.TargetURL == "" { - continue - } - - repoLink, err := getRepoLinkCached(x, repoLinkCache, row.RepoID) - if err != nil || repoLink == "" { - if err != nil { - log.Warn("convert %s id=%d getRepoLinkCached: %v", table, row.ID, err) - } else { - log.Warn("convert %s id=%d: repo=%d not found", table, row.ID, row.RepoID) - } - continue - } - - runNum, jobNum, ok := parseTargetURL(row.TargetURL, repoLink) - if !ok { - continue - } - - run, err := getRunByIndexCached(x, runByIndexCache, row.RepoID, runNum) - if err != nil || run == nil { - if err != nil { - log.Warn("convert %s id=%d getRunByIndexCached: %v", table, row.ID, err) - } else { - log.Warn("convert %s id=%d: run not found for repo_id=%d run_index=%d", table, row.ID, row.RepoID, runNum) - } - continue - } - - jobID, ok, err := getJobIDByIndexCached(x, jobsByRunIDCache, run.ID, jobNum) - if err != nil || !ok { - if err != nil { - log.Warn("convert %s id=%d getJobIDByIndexCached: %v", table, row.ID, err) - } else { - log.Warn("convert %s id=%d: job not found for run_id=%d job_index=%d", table, row.ID, run.ID, jobNum) - } - continue - } - - oldURL := row.TargetURL - newURL := fmt.Sprintf("%s%s%d/jobs/%d", repoLink, actionsRunPath, run.ID, jobID) // expect: {repo_link}/actions/runs/{run_id}/jobs/{job_id} - if oldURL == newURL { - continue - } - - if _, err := x.Table(table).ID(row.ID).Cols("target_url").Update(&migrationCommitStatus{TargetURL: newURL}); err != nil { - return fmt.Errorf("update %s id=%d target_url from %s to %s: %w", table, row.ID, oldURL, newURL, err) - } - } - } -} - -func getRepoLinkCached(x *xorm.Engine, cache map[int64]string, repoID int64) (string, error) { - if link, ok := cache[repoID]; ok { - return link, nil - } - repo := &migrationRepository{} - has, err := x.Table("repository").Where("id=?", repoID).Get(repo) - if err != nil { - return "", err - } - if !has { - cache[repoID] = "" - return "", nil - } - link := setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) - cache[repoID] = link - return link, nil -} - -func getRunByIndexCached(x *xorm.Engine, cache map[int64]map[int64]*migrationActionRun, repoID, runIndex int64) (*migrationActionRun, error) { - if repoCache, ok := cache[repoID]; ok { - if run, ok := repoCache[runIndex]; ok { - if run == nil { - return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) - } - return run, nil - } - } - - var run migrationActionRun - has, err := x.Table("action_run").Where("repo_id=? AND `index`=?", repoID, runIndex).Get(&run) - if err != nil { - return nil, err - } - if !has { - if cache[repoID] == nil { - cache[repoID] = make(map[int64]*migrationActionRun) - } - cache[repoID][runIndex] = nil - return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) - } - if cache[repoID] == nil { - cache[repoID] = make(map[int64]*migrationActionRun) - } - cache[repoID][runIndex] = &run - return &run, nil -} - -func getJobIDByIndexCached(x *xorm.Engine, cache map[int64][]int64, runID, jobIndex int64) (int64, bool, error) { - jobIDs, ok := cache[runID] - if !ok { - var jobs []migrationActionRunJob - if err := x.Table("action_run_job").Where("run_id=?", runID).Asc("id").Cols("id").Find(&jobs); err != nil { - return 0, false, err - } - jobIDs = make([]int64, 0, len(jobs)) - for _, job := range jobs { - jobIDs = append(jobIDs, job.ID) - } - cache[runID] = jobIDs - } - if jobIndex < 0 || jobIndex >= int64(len(jobIDs)) { - return 0, false, nil - } - return jobIDs[jobIndex], true, nil -} - -func parseTargetURL(targetURL, repoLink string) (runNum, jobNum int64, ok bool) { - prefix := repoLink + actionsRunPath - if !strings.HasPrefix(targetURL, prefix) { - return 0, 0, false - } - rest := targetURL[len(prefix):] - - parts := strings.Split(rest, "/") // expect: {run_num}/jobs/{job_num} - if len(parts) == 3 && parts[1] == "jobs" { - runNum, err1 := strconv.ParseInt(parts[0], 10, 64) - jobNum, err2 := strconv.ParseInt(parts[2], 10, 64) - if err1 != nil || err2 != nil { - return 0, 0, false - } - return runNum, jobNum, true - } - - return 0, 0, false + return x.Sync(new(CommitComment)) } diff --git a/models/repo/commit_comment.go b/models/repo/commit_comment.go new file mode 100644 index 0000000000..e13d127bc1 --- /dev/null +++ b/models/repo/commit_comment.go @@ -0,0 +1,155 @@ +// Copyright 2026 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" +) + +// CommitComment represents an inline comment on a commit diff. +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + TreePath string `xorm:"VARCHAR(4000) NOT NULL"` + Line int64 `xorm:"NOT NULL"` // negative = old side, positive = new side + PosterID int64 `xorm:"INDEX NOT NULL"` + Poster *user_model.User `xorm:"-"` + Content string `xorm:"LONGTEXT NOT NULL"` + RenderedContent template.HTML `xorm:"-"` + Patch string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// HashTag returns a unique tag for the comment, used for anchoring. +func (c *CommitComment) HashTag() string { + return fmt.Sprintf("commitcomment-%d", c.ID) +} + +// UnsignedLine returns the absolute value of the line number. +func (c *CommitComment) UnsignedLine() int64 { + if c.Line < 0 { + return -c.Line + } + return c.Line +} + +// GetCommentSide returns "previous" for old side (negative Line), "proposed" for new side. +func (c *CommitComment) GetCommentSide() string { + if c.Line < 0 { + return "previous" + } + return "proposed" +} + +// LoadPoster loads the poster user for a commit comment. +func (c *CommitComment) LoadPoster(ctx context.Context) error { + if c.Poster != nil || c.PosterID <= 0 { + return nil + } + poster, err := user_model.GetUserByID(ctx, c.PosterID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + c.PosterID = user_model.GhostUserID + c.Poster = user_model.NewGhostUser() + return nil + } + return err + } + c.Poster = poster + return nil +} + +// FileCommitComments holds commit comments for a single file, +// split by side (left = old, right = new) with int keys matching DiffLine indices. +type FileCommitComments struct { + Left map[int][]*CommitComment + Right map[int][]*CommitComment +} + +// CommitCommentsForDiff maps file paths to their commit comments. +type CommitCommentsForDiff map[string]*FileCommitComments + +// FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. +func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*CommitComment, error) { + comments := make([]*CommitComment, 0, 10) + return comments, db.GetEngine(ctx). + Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). + OrderBy("created_unix ASC"). + Find(&comments) +} + +// FindCommitCommentsForDiff returns comments grouped by path and side for rendering in a diff view. +func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA string) (CommitCommentsForDiff, error) { + comments, err := FindCommitCommentsByCommitSHA(ctx, repoID, commitSHA) + if err != nil { + return nil, err + } + + result := make(CommitCommentsForDiff) + for _, c := range comments { + if err := c.LoadPoster(ctx); err != nil { + return nil, err + } + fcc, ok := result[c.TreePath] + if !ok { + fcc = &FileCommitComments{ + Left: make(map[int][]*CommitComment), + Right: make(map[int][]*CommitComment), + } + result[c.TreePath] = fcc + } + if c.Line < 0 { + idx := int(-c.Line) + fcc.Left[idx] = append(fcc.Left[idx], c) + } else { + idx := int(c.Line) + fcc.Right[idx] = append(fcc.Right[idx], c) + } + } + return result, nil +} + +// CreateCommitComment inserts a new commit comment. +func CreateCommitComment(ctx context.Context, c *CommitComment) error { + _, err := db.GetEngine(ctx).Insert(c) + return err +} + +// GetCommitCommentByID returns a commit comment by its ID. +func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) { + c := &CommitComment{} + has, err := db.GetEngine(ctx).ID(id).Get(c) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "CommitComment", ID: id} + } + return c, nil +} + +// DeleteCommitComment deletes a commit comment by ID. +func DeleteCommitComment(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(&CommitComment{}) + return err +} + +// CountCommitCommentsByCommitSHA returns the count of comments for a commit. +func CountCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) (int64, error) { + return db.GetEngine(ctx). + Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). + Count(&CommitComment{}) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 168d959494..08745a8d6e 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" @@ -424,6 +425,35 @@ func Diff(ctx *context.Context) { ctx.Data["MergedPRIssueNumber"] = pr.Index } + // Load inline commit comments for the diff view + commitComments, err := repo_model.FindCommitCommentsForDiff(ctx, ctx.Repo.Repository.ID, commitID) + if err != nil { + log.Error("FindCommitCommentsForDiff: %v", err) + } + // Render markdown content for each commit comment + for _, fcc := range commitComments { + for _, comments := range fcc.Left { + for _, c := range comments { + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) + c.RenderedContent, err = markdown.RenderString(rctx, c.Content) + if err != nil { + log.Error("RenderString for commit comment %d: %v", c.ID, err) + } + } + } + for _, comments := range fcc.Right { + for _, c := range comments { + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) + c.RenderedContent, err = markdown.RenderString(rctx, c.Content) + if err != nil { + log.Error("RenderString for commit comment %d: %v", c.ID, err) + } + } + } + } + ctx.Data["CommitComments"] = commitComments + ctx.Data["CanComment"] = ctx.Doer != nil && ctx.Repo.CanRead(unit_model.TypeCode) + ctx.HTML(http.StatusOK, tplCommitPage) } diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go new file mode 100644 index 0000000000..fbf1d86460 --- /dev/null +++ b/routers/web/repo/commit_comment.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/renderhelper" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/gitdiff" +) + +var ( + tplNewCommitComment templates.TplName = "repo/diff/new_commit_comment" + tplCommitConversation templates.TplName = "repo/diff/commit_conversation" +) + +// RenderNewCommitCommentForm renders the comment form for inline commit comments. +func RenderNewCommitCommentForm(ctx *context.Context) { + commitSHA := ctx.PathParam("sha") + ctx.Data["CommitID"] = commitSHA + ctx.Data["PageIsDiff"] = true + ctx.HTML(http.StatusOK, tplNewCommitComment) +} + +// CreateCommitComment handles creating an inline comment on a commit diff. +func CreateCommitComment(ctx *context.Context) { + commitSHA := ctx.PathParam("sha") + if commitSHA == "" { + ctx.NotFound(nil) + return + } + + content := ctx.FormString("content") + treePath := ctx.FormString("tree_path") + if treePath == "" { + treePath = ctx.FormString("path") + } + side := ctx.FormString("side") + line := ctx.FormInt64("line") + + if content == "" || treePath == "" || line == 0 { + ctx.JSONError("content, tree_path, and line are required") + return + } + + // Negate line number for "previous" (old) side + if side == "previous" { + line = -line + } + + // Resolve full commit SHA + commit, err := ctx.Repo.GitRepo.GetCommit(commitSHA) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) + } else { + ctx.ServerError("GetCommit", err) + } + return + } + fullSHA := commit.ID.String() + + // Generate diff context patch around the commented line + var patch string + var parentSHA string + if commit.ParentCount() > 0 { + parentID, err := commit.ParentID(0) + if err == nil { + parentSHA = parentID.String() + } + } + if parentSHA != "" { + absLine := line + isOld := line < 0 + if isOld { + absLine = -line + } + patch, err = git.GetFileDiffCutAroundLine( + ctx.Repo.GitRepo, parentSHA, fullSHA, treePath, + absLine, isOld, setting.UI.CodeCommentLines, + ) + if err != nil { + log.Debug("GetFileDiffCutAroundLine failed for commit comment: %v", err) + } + if patch == "" { + patch, err = gitdiff.GeneratePatchForUnchangedLine(ctx.Repo.GitRepo, fullSHA, treePath, line, setting.UI.CodeCommentLines) + if err != nil { + log.Debug("GeneratePatchForUnchangedLine failed for commit comment: %v", err) + } + } + } + + comment := &repo_model.CommitComment{ + RepoID: ctx.Repo.Repository.ID, + CommitSHA: fullSHA, + TreePath: treePath, + Line: line, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + Content: content, + Patch: patch, + } + + if err := repo_model.CreateCommitComment(ctx, comment); err != nil { + ctx.ServerError("CreateCommitComment", err) + return + } + + // Render markdown content + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) + comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content) + if err != nil { + log.Error("RenderString for commit comment %d: %v", comment.ID, err) + } + + // Return the conversation HTML so JS can replace the form inline + ctx.Data["CommitID"] = fullSHA + ctx.Data["comments"] = []*repo_model.CommitComment{comment} + ctx.HTML(http.StatusOK, tplCommitConversation) +} + +// DeleteCommitComment handles deleting an inline comment on a commit. +func DeleteCommitComment(ctx *context.Context) { + commentID := ctx.PathParamInt64("id") + if commentID <= 0 { + ctx.NotFound(nil) + return + } + + comment, err := repo_model.GetCommitCommentByID(ctx, commentID) + if err != nil { + ctx.NotFound(err) + return + } + + // Only the poster or repo admin can delete + if comment.PosterID != ctx.Doer.ID && !ctx.Repo.IsAdmin() { + ctx.JSONError("permission denied") + return + } + + if err := repo_model.DeleteCommitComment(ctx, commentID); err != nil { + ctx.ServerError("DeleteCommitComment", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{"ok": true}) +} diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4..be1ce1b1cb 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1685,6 +1685,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.RenderNewCommitCommentForm) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.CreateCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, repo.DeleteCommitComment) // 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/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 390e41ec34..b81df83c73 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -184,7 +184,7 @@ {{end}} {{else}} - +
{{if $.IsSplitStyle}} {{template "repo/diff/section_split" dict "file" . "root" $}} {{else}} diff --git a/templates/repo/diff/commit_comment_form.tmpl b/templates/repo/diff/commit_comment_form.tmpl new file mode 100644 index 0000000000..88fa85827c --- /dev/null +++ b/templates/repo/diff/commit_comment_form.tmpl @@ -0,0 +1,25 @@ +{{if and ctx.RootData.SignedUserID (not ctx.RootData.Repository.IsArchived)}} + + + + +
+ {{template "shared/combomarkdowneditor" (dict + "CustomInit" true + "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment ctx.RootData.Repository) + "TextareaName" "content" + "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder") + "DropzoneParentContainer" "form" + "DisableAutosize" "true" + )}} +
+ + +{{end}} diff --git a/templates/repo/diff/commit_comments.tmpl b/templates/repo/diff/commit_comments.tmpl new file mode 100644 index 0000000000..64dc7a1763 --- /dev/null +++ b/templates/repo/diff/commit_comments.tmpl @@ -0,0 +1,35 @@ +{{range .comments}} + +{{$createdStr := DateUtils.TimeSince .CreatedUnix}} +
+
+ {{template "shared/user/avatarlink" dict "user" .Poster}} +
+
+
+
+ + {{template "shared/user/namelink" .Poster}} + {{ctx.Locale.Tr "repo.issues.commented_at" .HashTag $createdStr}} + +
+
+ {{if and $.root.IsSigned (eq $.root.SignedUserID .PosterID)}} +
+ {{svg "octicon-trash" 14}} +
+ {{end}} +
+
+
+
+ {{if .RenderedContent}} + {{.RenderedContent}} + {{else}} + {{ctx.Locale.Tr "repo.issues.no_content"}} + {{end}} +
+
+
+
+{{end}} diff --git a/templates/repo/diff/commit_conversation.tmpl b/templates/repo/diff/commit_conversation.tmpl new file mode 100644 index 0000000000..bc6e689d3f --- /dev/null +++ b/templates/repo/diff/commit_conversation.tmpl @@ -0,0 +1,25 @@ +{{if .comments}} + {{$comment := index .comments 0}} +
+
+
+
+ {{template "repo/diff/commit_comments" dict "root" $ "comments" .comments}} +
+
+
+
+ + +
+
+ {{if and $.SignedUserID (not $.Repository.IsArchived)}} + {{template "repo/diff/commit_comment_form" dict "hidden" true "root" $}} + {{end}} +
+
+{{end}} diff --git a/templates/repo/diff/new_commit_comment.tmpl b/templates/repo/diff/new_commit_comment.tmpl new file mode 100644 index 0000000000..fa54107609 --- /dev/null +++ b/templates/repo/diff/new_commit_comment.tmpl @@ -0,0 +1,5 @@ +
+
+ {{template "repo/diff/commit_comment_form" dict "root" $}} +
+
diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index ab23b1b934..f892a817b7 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -1,5 +1,8 @@ {{$file := .file}} {{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} +{{$commitComments := $.root.CommitComments}} +{{$ccFile := ""}} +{{if $commitComments}}{{$ccFile = index $commitComments $file.Name}}{{end}} @@ -28,7 +31,7 @@ {{end}} + {{/* Render commit comments (not PR review comments) */}} + {{if $ccFile}} + {{$leftCC := ""}} + {{if $line.LeftIdx}}{{$leftCC = index $ccFile.Left $line.LeftIdx}}{{end}} + {{$rightCC := ""}} + {{if and (eq .GetType 3) $hasmatch}} + {{$match := index $section.Lines $line.Match}} + {{if $match.RightIdx}}{{$rightCC = index $ccFile.Right $match.RightIdx}}{{end}} + {{else}} + {{if $line.RightIdx}}{{$rightCC = index $ccFile.Right $line.RightIdx}}{{end}} + {{end}} + {{if or $leftCC $rightCC}} + + + + + {{end}} + {{end}} {{end}} {{end}} {{end}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index 6776198b75..cda07c5de0 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -1,6 +1,9 @@ {{$file := .file}} {{/* this tmpl is also used by the PR Conversation page, so "DiffBlobExcerptData" may not exist */}} {{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} +{{$commitComments := $.root.CommitComments}} +{{$ccFile := ""}} +{{if $commitComments}}{{$ccFile = index $commitComments $file.Name}}{{end}} @@ -33,7 +36,7 @@ {{else}} {{end}} + {{/* Render commit comments (not PR review comments) */}} + {{if $ccFile}} + {{$rightCC := ""}} + {{if $line.RightIdx}}{{$rightCC = index $ccFile.Right $line.RightIdx}}{{end}} + {{$leftCC := ""}} + {{if and $line.LeftIdx (not $line.RightIdx)}}{{$leftCC = index $ccFile.Left $line.LeftIdx}}{{end}} + {{if or $rightCC $leftCC}} + + + + {{end}} + {{end}} {{end}} {{end}} From 9a4cc1ce4a5c70d3fd27920a5d52a22fa918274c Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 8 Mar 2026 15:27:17 +0530 Subject: [PATCH 02/13] fix: align var block formatting --- routers/web/repo/commit_comment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index fbf1d86460..9b906064f0 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -18,8 +18,8 @@ import ( ) var ( - tplNewCommitComment templates.TplName = "repo/diff/new_commit_comment" - tplCommitConversation templates.TplName = "repo/diff/commit_conversation" + tplNewCommitComment templates.TplName = "repo/diff/new_commit_comment" + tplCommitConversation templates.TplName = "repo/diff/commit_conversation" ) // RenderNewCommitCommentForm renders the comment form for inline commit comments. From 3a081157683cb9d2c5661cc36ec6ef5b645d987e Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 9 Mar 2026 02:00:48 +0530 Subject: [PATCH 03/13] refactor: use Comment table with junction table for commit comments Per @lunny's feedback, rework to reuse the existing Comment table instead of a standalone commit_comment table. The junction table (commit_comment) now only stores repo_id, commit_sha, comment_id. Actual comment data (content, tree_path, line, patch, poster) lives in the Comment table with Type = CommentTypeCommitComment (39). This gives commit comments reactions, attachments, and all existing comment infrastructure for free. --- models/issues/comment.go | 7 +- models/issues/commit_comment.go | 139 +++++++++++++++++ models/migrations/v1_26/v326.go | 20 +-- models/repo/commit_comment.go | 155 +------------------ routers/web/repo/commit.go | 2 +- routers/web/repo/commit_comment.go | 30 ++-- templates/repo/diff/commit_conversation.tmpl | 2 +- 7 files changed, 171 insertions(+), 184 deletions(-) create mode 100644 models/issues/commit_comment.go diff --git a/models/issues/comment.go b/models/issues/comment.go index 25e74c01ea..1e326d8a21 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -116,6 +116,8 @@ const ( CommentTypeUnpin // 37 unpin Issue/PullRequest CommentTypeChangeTimeEstimate // 38 Change time estimate + + CommentTypeCommitComment // 39 Inline comment on a commit diff (not part of a PR review) ) var commentStrings = []string{ @@ -158,6 +160,7 @@ var commentStrings = []string{ "pin", "unpin", "change_time_estimate", + "commit_comment", } func (t CommentType) String() string { @@ -175,7 +178,7 @@ func AsCommentType(typeName string) CommentType { func (t CommentType) HasContentSupport() bool { switch t { - case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview: + case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeCommitComment: return true } return false @@ -183,7 +186,7 @@ func (t CommentType) HasContentSupport() bool { func (t CommentType) HasAttachmentSupport() bool { switch t { - case CommentTypeComment, CommentTypeCode, CommentTypeReview: + case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeCommitComment: return true } return false diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go new file mode 100644 index 0000000000..2b4e38a43f --- /dev/null +++ b/models/issues/commit_comment.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// CommitComment is a junction table linking a commit (repo + SHA) to +// a Comment entry. The comment content, tree_path, line, poster, etc. +// are stored in the Comment table with Type = CommentTypeCommitComment. +type CommitComment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + CommentID int64 `xorm:"UNIQUE NOT NULL"` +} + +func init() { + db.RegisterModel(new(CommitComment)) +} + +// FileCommitComments holds commit comments for a single file, +// split by side (left = old, right = new) with int keys matching DiffLine indices. +type FileCommitComments struct { + Left map[int][]*Comment + Right map[int][]*Comment +} + +// CommitCommentsForDiff maps file paths to their commit comments. +type CommitCommentsForDiff map[string]*FileCommitComments + +// FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. +func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*Comment, error) { + var refs []CommitComment + if err := db.GetEngine(ctx). + Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). + Find(&refs); err != nil { + return nil, err + } + + if len(refs) == 0 { + return nil, nil + } + + commentIDs := make([]int64, 0, len(refs)) + for _, ref := range refs { + commentIDs = append(commentIDs, ref.CommentID) + } + + comments := make([]*Comment, 0, len(commentIDs)) + if err := db.GetEngine(ctx). + In("id", commentIDs). + OrderBy("created_unix ASC"). + Find(&comments); err != nil { + return nil, err + } + + for _, c := range comments { + if err := c.LoadPoster(ctx); err != nil { + return nil, err + } + } + + return comments, nil +} + +// FindCommitCommentsForDiff returns comments grouped by path and side for rendering in a diff view. +func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA string) (CommitCommentsForDiff, error) { + comments, err := FindCommitCommentsByCommitSHA(ctx, repoID, commitSHA) + if err != nil { + return nil, err + } + + result := make(CommitCommentsForDiff) + for _, c := range comments { + fcc, ok := result[c.TreePath] + if !ok { + fcc = &FileCommitComments{ + Left: make(map[int][]*Comment), + Right: make(map[int][]*Comment), + } + result[c.TreePath] = fcc + } + if c.Line < 0 { + idx := int(-c.Line) + fcc.Left[idx] = append(fcc.Left[idx], c) + } else { + idx := int(c.Line) + fcc.Right[idx] = append(fcc.Right[idx], c) + } + } + return result, nil +} + +// CreateCommitComment creates a Comment with type CommitComment and a +// corresponding CommitComment junction record, within a transaction. +func CreateCommitComment(ctx context.Context, repoID int64, commitSHA string, comment *Comment) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := db.GetEngine(ctx).Insert(comment); err != nil { + return err + } + + ref := &CommitComment{ + RepoID: repoID, + CommitSHA: commitSHA, + CommentID: comment.ID, + } + _, err := db.GetEngine(ctx).Insert(ref) + return err + }) +} + +// DeleteCommitComment deletes both the junction record and the Comment entry. +func DeleteCommitComment(ctx context.Context, commentID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if _, err := db.GetEngine(ctx).Where("comment_id = ?", commentID).Delete(&CommitComment{}); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(commentID).Delete(&Comment{}) + return err + }) +} + +// GetCommitCommentByID returns a commit comment by loading the Comment entry. +func GetCommitCommentByID(ctx context.Context, commentID int64) (*Comment, error) { + c := &Comment{} + has, err := db.GetEngine(ctx).ID(commentID).Get(c) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "CommitComment", ID: commentID} + } + return c, nil +} diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 387a183bbc..3c3a751aa7 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -4,23 +4,19 @@ package v1_26 import ( - "code.gitea.io/gitea/modules/timeutil" - "xorm.io/xorm" ) func AddCommitCommentTable(x *xorm.Engine) error { + // CommitComment is a junction table that maps commit-specific context + // (repo, commit SHA) to a Comment entry. The actual comment content, + // tree_path, line, poster, etc. live in the Comment table with + // type = CommentTypeCommitComment (39). type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - TreePath string `xorm:"VARCHAR(4000) NOT NULL"` - Line int64 `xorm:"NOT NULL"` - PosterID int64 `xorm:"INDEX NOT NULL"` - Content string `xorm:"LONGTEXT NOT NULL"` - Patch string `xorm:"LONGTEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` + CommentID int64 `xorm:"UNIQUE NOT NULL"` } return x.Sync(new(CommitComment)) diff --git a/models/repo/commit_comment.go b/models/repo/commit_comment.go index e13d127bc1..74657a72a0 100644 --- a/models/repo/commit_comment.go +++ b/models/repo/commit_comment.go @@ -1,155 +1,8 @@ // Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT +// This file intentionally left minimal. The CommitComment junction table +// and all query methods now live in models/issues/commit_comment.go +// alongside the Comment model they reference. + package repo - -import ( - "context" - "fmt" - "html/template" - - "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/timeutil" -) - -// CommitComment represents an inline comment on a commit diff. -type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - TreePath string `xorm:"VARCHAR(4000) NOT NULL"` - Line int64 `xorm:"NOT NULL"` // negative = old side, positive = new side - PosterID int64 `xorm:"INDEX NOT NULL"` - Poster *user_model.User `xorm:"-"` - Content string `xorm:"LONGTEXT NOT NULL"` - RenderedContent template.HTML `xorm:"-"` - Patch string `xorm:"LONGTEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` -} - -func init() { - db.RegisterModel(new(CommitComment)) -} - -// HashTag returns a unique tag for the comment, used for anchoring. -func (c *CommitComment) HashTag() string { - return fmt.Sprintf("commitcomment-%d", c.ID) -} - -// UnsignedLine returns the absolute value of the line number. -func (c *CommitComment) UnsignedLine() int64 { - if c.Line < 0 { - return -c.Line - } - return c.Line -} - -// GetCommentSide returns "previous" for old side (negative Line), "proposed" for new side. -func (c *CommitComment) GetCommentSide() string { - if c.Line < 0 { - return "previous" - } - return "proposed" -} - -// LoadPoster loads the poster user for a commit comment. -func (c *CommitComment) LoadPoster(ctx context.Context) error { - if c.Poster != nil || c.PosterID <= 0 { - return nil - } - poster, err := user_model.GetUserByID(ctx, c.PosterID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - c.PosterID = user_model.GhostUserID - c.Poster = user_model.NewGhostUser() - return nil - } - return err - } - c.Poster = poster - return nil -} - -// FileCommitComments holds commit comments for a single file, -// split by side (left = old, right = new) with int keys matching DiffLine indices. -type FileCommitComments struct { - Left map[int][]*CommitComment - Right map[int][]*CommitComment -} - -// CommitCommentsForDiff maps file paths to their commit comments. -type CommitCommentsForDiff map[string]*FileCommitComments - -// FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. -func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*CommitComment, error) { - comments := make([]*CommitComment, 0, 10) - return comments, db.GetEngine(ctx). - Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - OrderBy("created_unix ASC"). - Find(&comments) -} - -// FindCommitCommentsForDiff returns comments grouped by path and side for rendering in a diff view. -func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA string) (CommitCommentsForDiff, error) { - comments, err := FindCommitCommentsByCommitSHA(ctx, repoID, commitSHA) - if err != nil { - return nil, err - } - - result := make(CommitCommentsForDiff) - for _, c := range comments { - if err := c.LoadPoster(ctx); err != nil { - return nil, err - } - fcc, ok := result[c.TreePath] - if !ok { - fcc = &FileCommitComments{ - Left: make(map[int][]*CommitComment), - Right: make(map[int][]*CommitComment), - } - result[c.TreePath] = fcc - } - if c.Line < 0 { - idx := int(-c.Line) - fcc.Left[idx] = append(fcc.Left[idx], c) - } else { - idx := int(c.Line) - fcc.Right[idx] = append(fcc.Right[idx], c) - } - } - return result, nil -} - -// CreateCommitComment inserts a new commit comment. -func CreateCommitComment(ctx context.Context, c *CommitComment) error { - _, err := db.GetEngine(ctx).Insert(c) - return err -} - -// GetCommitCommentByID returns a commit comment by its ID. -func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) { - c := &CommitComment{} - has, err := db.GetEngine(ctx).ID(id).Get(c) - if err != nil { - return nil, err - } - if !has { - return nil, db.ErrNotExist{Resource: "CommitComment", ID: id} - } - return c, nil -} - -// DeleteCommitComment deletes a commit comment by ID. -func DeleteCommitComment(ctx context.Context, id int64) error { - _, err := db.GetEngine(ctx).ID(id).Delete(&CommitComment{}) - return err -} - -// CountCommitCommentsByCommitSHA returns the count of comments for a commit. -func CountCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) (int64, error) { - return db.GetEngine(ctx). - Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - Count(&CommitComment{}) -} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 08745a8d6e..4a9a1b2b76 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -426,7 +426,7 @@ func Diff(ctx *context.Context) { } // Load inline commit comments for the diff view - commitComments, err := repo_model.FindCommitCommentsForDiff(ctx, ctx.Repo.Repository.ID, commitID) + commitComments, err := issues_model.FindCommitCommentsForDiff(ctx, ctx.Repo.Repository.ID, commitID) if err != nil { log.Error("FindCommitCommentsForDiff: %v", err) } diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index 9b906064f0..44bd24fd5a 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -6,8 +6,8 @@ package repo import ( "net/http" + issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" - repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -51,12 +51,10 @@ func CreateCommitComment(ctx *context.Context) { return } - // Negate line number for "previous" (old) side if side == "previous" { line = -line } - // Resolve full commit SHA commit, err := ctx.Repo.GitRepo.GetCommit(commitSHA) if err != nil { if git.IsErrNotExist(err) { @@ -98,18 +96,18 @@ func CreateCommitComment(ctx *context.Context) { } } - comment := &repo_model.CommitComment{ - RepoID: ctx.Repo.Repository.ID, + comment := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitComment, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, CommitSHA: fullSHA, - TreePath: treePath, - Line: line, - PosterID: ctx.Doer.ID, - Poster: ctx.Doer, - Content: content, - Patch: patch, + TreePath: treePath, + Line: line, + Content: content, + Patch: patch, } - if err := repo_model.CreateCommitComment(ctx, comment); err != nil { + if err := issues_model.CreateCommitComment(ctx, ctx.Repo.Repository.ID, fullSHA, comment); err != nil { ctx.ServerError("CreateCommitComment", err) return } @@ -121,9 +119,8 @@ func CreateCommitComment(ctx *context.Context) { log.Error("RenderString for commit comment %d: %v", comment.ID, err) } - // Return the conversation HTML so JS can replace the form inline ctx.Data["CommitID"] = fullSHA - ctx.Data["comments"] = []*repo_model.CommitComment{comment} + ctx.Data["comments"] = []*issues_model.Comment{comment} ctx.HTML(http.StatusOK, tplCommitConversation) } @@ -135,19 +132,18 @@ func DeleteCommitComment(ctx *context.Context) { return } - comment, err := repo_model.GetCommitCommentByID(ctx, commentID) + comment, err := issues_model.GetCommitCommentByID(ctx, commentID) if err != nil { ctx.NotFound(err) return } - // Only the poster or repo admin can delete if comment.PosterID != ctx.Doer.ID && !ctx.Repo.IsAdmin() { ctx.JSONError("permission denied") return } - if err := repo_model.DeleteCommitComment(ctx, commentID); err != nil { + if err := issues_model.DeleteCommitComment(ctx, commentID); err != nil { ctx.ServerError("DeleteCommitComment", err) return } diff --git a/templates/repo/diff/commit_conversation.tmpl b/templates/repo/diff/commit_conversation.tmpl index bc6e689d3f..9577091693 100644 --- a/templates/repo/diff/commit_conversation.tmpl +++ b/templates/repo/diff/commit_conversation.tmpl @@ -1,6 +1,6 @@ {{if .comments}} {{$comment := index .comments 0}} -
+
From 17c3b06b2169ce6c01b55c73d3629c9435fec536 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 9 Mar 2026 02:16:57 +0530 Subject: [PATCH 04/13] fix: address lunny's review comments - use single query with Cols("comment_id").Table("commit_comment") instead of loading full CommitComment structs - remove models/repo/commit_comment.go entirely --- models/issues/commit_comment.go | 13 ++++--------- models/repo/commit_comment.go | 8 -------- 2 files changed, 4 insertions(+), 17 deletions(-) delete mode 100644 models/repo/commit_comment.go diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go index 2b4e38a43f..297b97ce6c 100644 --- a/models/issues/commit_comment.go +++ b/models/issues/commit_comment.go @@ -35,22 +35,17 @@ type CommitCommentsForDiff map[string]*FileCommitComments // FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*Comment, error) { - var refs []CommitComment - if err := db.GetEngine(ctx). + var commentIDs []int64 + if err := db.GetEngine(ctx).Cols("comment_id").Table("commit_comment"). Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - Find(&refs); err != nil { + Find(&commentIDs); err != nil { return nil, err } - if len(refs) == 0 { + if len(commentIDs) == 0 { return nil, nil } - commentIDs := make([]int64, 0, len(refs)) - for _, ref := range refs { - commentIDs = append(commentIDs, ref.CommentID) - } - comments := make([]*Comment, 0, len(commentIDs)) if err := db.GetEngine(ctx). In("id", commentIDs). diff --git a/models/repo/commit_comment.go b/models/repo/commit_comment.go deleted file mode 100644 index 74657a72a0..0000000000 --- a/models/repo/commit_comment.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -// This file intentionally left minimal. The CommitComment junction table -// and all query methods now live in models/issues/commit_comment.go -// alongside the Comment model they reference. - -package repo From 74ebf2aa14d8ae86c09872bc2181db92a60018ba Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 9 Mar 2026 12:35:07 +0530 Subject: [PATCH 05/13] fix: align struct field formatting for gofmt --- routers/web/repo/commit_comment.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index 44bd24fd5a..10f772363c 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -97,14 +97,14 @@ func CreateCommitComment(ctx *context.Context) { } comment := &issues_model.Comment{ - Type: issues_model.CommentTypeCommitComment, - PosterID: ctx.Doer.ID, - Poster: ctx.Doer, + Type: issues_model.CommentTypeCommitComment, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, CommitSHA: fullSHA, - TreePath: treePath, - Line: line, - Content: content, - Patch: patch, + TreePath: treePath, + Line: line, + Content: content, + Patch: patch, } if err := issues_model.CreateCommitComment(ctx, ctx.Repo.Repository.ID, fullSHA, comment); err != nil { From 0f4ebcba100b994242b8695e4c594280823789fc Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 9 Mar 2026 14:56:45 +0530 Subject: [PATCH 06/13] fix: address review - add archive check, verify repo ownership, remove path fallback --- models/issues/commit_comment.go | 15 +++++++++++++-- routers/web/repo/commit_comment.go | 5 +---- routers/web/web.go | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go index 297b97ce6c..45d5b1bafb 100644 --- a/models/issues/commit_comment.go +++ b/models/issues/commit_comment.go @@ -120,8 +120,19 @@ func DeleteCommitComment(ctx context.Context, commentID int64) error { }) } -// GetCommitCommentByID returns a commit comment by loading the Comment entry. -func GetCommitCommentByID(ctx context.Context, commentID int64) (*Comment, error) { +// GetCommitCommentByID returns a commit comment by loading the Comment entry, +// verifying it belongs to the given repository via the junction table. +func GetCommitCommentByID(ctx context.Context, repoID, commentID int64) (*Comment, error) { + exists, err := db.GetEngine(ctx).Table("commit_comment"). + Where("repo_id = ? AND comment_id = ?", repoID, commentID). + Exist() + if err != nil { + return nil, err + } + if !exists { + return nil, db.ErrNotExist{Resource: "CommitComment", ID: commentID} + } + c := &Comment{} has, err := db.GetEngine(ctx).ID(commentID).Get(c) if err != nil { diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index 10f772363c..a3761daaf3 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -40,9 +40,6 @@ func CreateCommitComment(ctx *context.Context) { content := ctx.FormString("content") treePath := ctx.FormString("tree_path") - if treePath == "" { - treePath = ctx.FormString("path") - } side := ctx.FormString("side") line := ctx.FormInt64("line") @@ -132,7 +129,7 @@ func DeleteCommitComment(ctx *context.Context) { return } - comment, err := issues_model.GetCommitCommentByID(ctx, commentID) + comment, err := issues_model.GetCommitCommentByID(ctx, ctx.Repo.Repository.ID, commentID) if err != nil { ctx.NotFound(err) return diff --git a/routers/web/web.go b/routers/web/web.go index be1ce1b1cb..01cc33814f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1686,8 +1686,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.RenderNewCommitCommentForm) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.CreateCommitComment) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, repo.DeleteCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, context.RepoMustNotBeArchived(), repo.CreateCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) // 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) From 80cea11be0b269f0ec44bc6f8f01816149176ce7 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Wed, 11 Mar 2026 00:43:33 +0530 Subject: [PATCH 07/13] refactor: drop junction table, add RepoID to Comment model - Remove commit_comment junction table, query Comment directly via repo_id + commit_sha + type filter - Add RepoID column to Comment (migration v326) - Add reqRepoCodeWriter permission checks to all commit comment routes - Fix delete auth: use CanWrite(TypeCode) instead of IsAdmin() - Fix CanComment: gate on write access, not read - Guard LoadIssue against IssueID=0 (commit comments have no issue) - Extract duplicated markdown rendering loop in commit.go - Fix template dict keys: use "root" instead of "." - Show delete button for users with code write access - Add unit tests for create, delete, get, find operations --- models/issues/comment.go | 4 + models/issues/commit_comment.go | 82 ++------- models/issues/commit_comment_test.go | 167 +++++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_26/v326.go | 20 +-- models/migrations/v1_26/v331.go | 21 +++ routers/web/repo/commit.go | 19 +-- routers/web/repo/commit_comment.go | 12 +- routers/web/web.go | 6 +- templates/repo/diff/commit_comments.tmpl | 2 +- templates/repo/diff/commit_conversation.tmpl | 6 +- templates/repo/diff/section_split.tmpl | 4 +- templates/repo/diff/section_unified.tmpl | 4 +- 13 files changed, 244 insertions(+), 104 deletions(-) create mode 100644 models/issues/commit_comment_test.go create mode 100644 models/migrations/v1_26/v331.go diff --git a/models/issues/comment.go b/models/issues/comment.go index 1e326d8a21..eae61d803a 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -260,6 +260,7 @@ type Comment struct { OriginalAuthorID int64 IssueID int64 `xorm:"INDEX"` Issue *Issue `xorm:"-"` + RepoID int64 `xorm:"INDEX DEFAULT 0"` // used by commit comments (type=39) which don't have an issue LabelID int64 Label *Label `xorm:"-"` AddedLabels []*Label `xorm:"-"` @@ -351,6 +352,9 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) { if c.Issue != nil { return nil } + if c.IssueID == 0 { + return nil + } c.Issue, err = GetIssueByID(ctx, c.IssueID) return err } diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go index 45d5b1bafb..ef39255714 100644 --- a/models/issues/commit_comment.go +++ b/models/issues/commit_comment.go @@ -9,20 +9,6 @@ import ( "code.gitea.io/gitea/models/db" ) -// CommitComment is a junction table linking a commit (repo + SHA) to -// a Comment entry. The comment content, tree_path, line, poster, etc. -// are stored in the Comment table with Type = CommentTypeCommitComment. -type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - CommentID int64 `xorm:"UNIQUE NOT NULL"` -} - -func init() { - db.RegisterModel(new(CommitComment)) -} - // FileCommitComments holds commit comments for a single file, // split by side (left = old, right = new) with int keys matching DiffLine indices. type FileCommitComments struct { @@ -35,20 +21,9 @@ type CommitCommentsForDiff map[string]*FileCommitComments // FindCommitCommentsByCommitSHA returns all comments for a given commit in a repo. func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA string) ([]*Comment, error) { - var commentIDs []int64 - if err := db.GetEngine(ctx).Cols("comment_id").Table("commit_comment"). - Where("repo_id = ? AND commit_sha = ?", repoID, commitSHA). - Find(&commentIDs); err != nil { - return nil, err - } - - if len(commentIDs) == 0 { - return nil, nil - } - - comments := make([]*Comment, 0, len(commentIDs)) + comments := make([]*Comment, 0) if err := db.GetEngine(ctx). - In("id", commentIDs). + Where("repo_id = ? AND commit_sha = ? AND type = ?", repoID, commitSHA, CommentTypeCommitComment). OrderBy("created_unix ASC"). Find(&comments); err != nil { return nil, err @@ -91,50 +66,27 @@ func FindCommitCommentsForDiff(ctx context.Context, repoID int64, commitSHA stri return result, nil } -// CreateCommitComment creates a Comment with type CommitComment and a -// corresponding CommitComment junction record, within a transaction. -func CreateCommitComment(ctx context.Context, repoID int64, commitSHA string, comment *Comment) error { - return db.WithTx(ctx, func(ctx context.Context) error { - if _, err := db.GetEngine(ctx).Insert(comment); err != nil { - return err - } - - ref := &CommitComment{ - RepoID: repoID, - CommitSHA: commitSHA, - CommentID: comment.ID, - } - _, err := db.GetEngine(ctx).Insert(ref) - return err - }) +// CreateCommitComment creates a Comment with type CommitComment. +func CreateCommitComment(ctx context.Context, comment *Comment) error { + comment.Type = CommentTypeCommitComment + _, err := db.GetEngine(ctx).Insert(comment) + return err } -// DeleteCommitComment deletes both the junction record and the Comment entry. -func DeleteCommitComment(ctx context.Context, commentID int64) error { - return db.WithTx(ctx, func(ctx context.Context) error { - if _, err := db.GetEngine(ctx).Where("comment_id = ?", commentID).Delete(&CommitComment{}); err != nil { - return err - } - _, err := db.GetEngine(ctx).ID(commentID).Delete(&Comment{}) - return err - }) +// DeleteCommitComment deletes a commit comment by ID, verifying it belongs to the given repo. +func DeleteCommitComment(ctx context.Context, repoID, commentID int64) error { + _, err := db.GetEngine(ctx). + Where("id = ? AND repo_id = ? AND type = ?", commentID, repoID, CommentTypeCommitComment). + Delete(&Comment{}) + return err } -// GetCommitCommentByID returns a commit comment by loading the Comment entry, -// verifying it belongs to the given repository via the junction table. +// GetCommitCommentByID returns a commit comment by ID, verifying it belongs to the given repo. func GetCommitCommentByID(ctx context.Context, repoID, commentID int64) (*Comment, error) { - exists, err := db.GetEngine(ctx).Table("commit_comment"). - Where("repo_id = ? AND comment_id = ?", repoID, commentID). - Exist() - if err != nil { - return nil, err - } - if !exists { - return nil, db.ErrNotExist{Resource: "CommitComment", ID: commentID} - } - c := &Comment{} - has, err := db.GetEngine(ctx).ID(commentID).Get(c) + has, err := db.GetEngine(ctx). + Where("id = ? AND repo_id = ? AND type = ?", commentID, repoID, CommentTypeCommitComment). + Get(c) if err != nil { return nil, err } diff --git a/models/issues/commit_comment_test.go b/models/issues/commit_comment_test.go new file mode 100644 index 0000000000..af2e0e78d7 --- /dev/null +++ b/models/issues/commit_comment_test.go @@ -0,0 +1,167 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCommitComment(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + comment := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: "abc123def456", + TreePath: "README.md", + Line: 10, + Content: "looks good", + } + + err := issues_model.CreateCommitComment(t.Context(), comment) + require.NoError(t, err) + assert.Greater(t, comment.ID, int64(0)) + assert.Equal(t, issues_model.CommentTypeCommitComment, comment.Type) + + // Verify it's in the DB + loaded, err := issues_model.GetCommitCommentByID(t.Context(), 1, comment.ID) + require.NoError(t, err) + assert.Equal(t, "README.md", loaded.TreePath) + assert.Equal(t, int64(10), loaded.Line) + assert.Equal(t, "looks good", loaded.Content) + assert.Equal(t, int64(1), loaded.RepoID) +} + +func TestGetCommitCommentByID_WrongRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + comment := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: "abc123def456", + TreePath: "main.go", + Line: 5, + Content: "test", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), comment)) + + // Trying to load with wrong repo ID should fail + _, err := issues_model.GetCommitCommentByID(t.Context(), 999, comment.ID) + assert.True(t, db.IsErrNotExist(err)) +} + +func TestDeleteCommitComment(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + comment := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: "abc123def456", + TreePath: "README.md", + Line: 10, + Content: "to be deleted", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), comment)) + + err := issues_model.DeleteCommitComment(t.Context(), 1, comment.ID) + require.NoError(t, err) + + // Verify it's gone + _, err = issues_model.GetCommitCommentByID(t.Context(), 1, comment.ID) + assert.True(t, db.IsErrNotExist(err)) +} + +func TestDeleteCommitComment_WrongRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + comment := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: "abc123def456", + TreePath: "README.md", + Line: 10, + Content: "should not be deleted", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), comment)) + + // Delete with wrong repo should not actually delete + err := issues_model.DeleteCommitComment(t.Context(), 999, comment.ID) + require.NoError(t, err) + + // Verify it's still there + loaded, err := issues_model.GetCommitCommentByID(t.Context(), 1, comment.ID) + require.NoError(t, err) + assert.Equal(t, comment.ID, loaded.ID) +} + +func TestFindCommitCommentsByCommitSHA(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + sha := "deadbeef1234" + // Create two comments on same commit, different lines + for _, line := range []int64{5, -10} { + c := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: sha, + TreePath: "main.go", + Line: line, + Content: "comment", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), c)) + } + + comments, err := issues_model.FindCommitCommentsByCommitSHA(t.Context(), 1, sha) + require.NoError(t, err) + assert.Len(t, comments, 2) + + // Different repo should return empty + comments, err = issues_model.FindCommitCommentsByCommitSHA(t.Context(), 999, sha) + require.NoError(t, err) + assert.Empty(t, comments) +} + +func TestFindCommitCommentsForDiff(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + sha := "cafebabe5678" + // Left side comment (negative line = old/previous side) + c1 := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: sha, + TreePath: "file.go", + Line: -3, + Content: "old side", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), c1)) + + // Right side comment (positive line = new/proposed side) + c2 := &issues_model.Comment{ + PosterID: 2, + RepoID: 1, + CommitSHA: sha, + TreePath: "file.go", + Line: 7, + Content: "new side", + } + require.NoError(t, issues_model.CreateCommitComment(t.Context(), c2)) + + result, err := issues_model.FindCommitCommentsForDiff(t.Context(), 1, sha) + require.NoError(t, err) + + fcc, ok := result["file.go"] + require.True(t, ok) + assert.Len(t, fcc.Left[3], 1) + assert.Equal(t, "old side", fcc.Left[3][0].Content) + assert.Len(t, fcc.Right[7], 1) + assert.Equal(t, "new side", fcc.Right[7][0].Content) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index cad4156dee..0c6ea60418 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration { newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook), + newMigration(331, "Add repo_id column to comment table for commit comments", v1_26.AddCommitCommentColumns), } return preparedMigrations } diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 3c3a751aa7..5fdd62758f 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -7,17 +7,15 @@ import ( "xorm.io/xorm" ) -func AddCommitCommentTable(x *xorm.Engine) error { - // CommitComment is a junction table that maps commit-specific context - // (repo, commit SHA) to a Comment entry. The actual comment content, - // tree_path, line, poster, etc. live in the Comment table with - // type = CommentTypeCommitComment (39). - type CommitComment struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - CommitSHA string `xorm:"VARCHAR(64) INDEX NOT NULL"` - CommentID int64 `xorm:"UNIQUE NOT NULL"` +func AddCommitCommentColumns(x *xorm.Engine) error { + // Add RepoID column to the comment table for commit comments. + // Commit comments (type=39) store repo_id directly on the comment + // instead of deriving it through issue_id, since they don't belong + // to any issue. Combined with the existing commit_sha column, this + // allows querying commit comments without a junction table. + type Comment struct { + RepoID int64 `xorm:"INDEX DEFAULT 0"` } - return x.Sync(new(CommitComment)) + return x.Sync(new(Comment)) } diff --git a/models/migrations/v1_26/v331.go b/models/migrations/v1_26/v331.go new file mode 100644 index 0000000000..5fdd62758f --- /dev/null +++ b/models/migrations/v1_26/v331.go @@ -0,0 +1,21 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func AddCommitCommentColumns(x *xorm.Engine) error { + // Add RepoID column to the comment table for commit comments. + // Commit comments (type=39) store repo_id directly on the comment + // instead of deriving it through issue_id, since they don't belong + // to any issue. Combined with the existing commit_sha column, this + // allows querying commit comments without a junction table. + type Comment struct { + RepoID int64 `xorm:"INDEX DEFAULT 0"` + } + + return x.Sync(new(Comment)) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 4a9a1b2b76..d33c6da5bc 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -431,17 +431,8 @@ func Diff(ctx *context.Context) { log.Error("FindCommitCommentsForDiff: %v", err) } // Render markdown content for each commit comment - for _, fcc := range commitComments { - for _, comments := range fcc.Left { - for _, c := range comments { - rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) - c.RenderedContent, err = markdown.RenderString(rctx, c.Content) - if err != nil { - log.Error("RenderString for commit comment %d: %v", c.ID, err) - } - } - } - for _, comments := range fcc.Right { + renderCommitComments := func(commentsByLine map[int][]*issues_model.Comment) { + for _, comments := range commentsByLine { for _, c := range comments { rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) c.RenderedContent, err = markdown.RenderString(rctx, c.Content) @@ -451,8 +442,12 @@ func Diff(ctx *context.Context) { } } } + for _, fcc := range commitComments { + renderCommitComments(fcc.Left) + renderCommitComments(fcc.Right) + } ctx.Data["CommitComments"] = commitComments - ctx.Data["CanComment"] = ctx.Doer != nil && ctx.Repo.CanRead(unit_model.TypeCode) + ctx.Data["CanComment"] = ctx.Doer != nil && ctx.Repo.CanWrite(unit_model.TypeCode) ctx.HTML(http.StatusOK, tplCommitPage) } diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index a3761daaf3..5b17341597 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -8,6 +8,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" + unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -94,9 +95,9 @@ func CreateCommitComment(ctx *context.Context) { } comment := &issues_model.Comment{ - Type: issues_model.CommentTypeCommitComment, PosterID: ctx.Doer.ID, Poster: ctx.Doer, + RepoID: ctx.Repo.Repository.ID, CommitSHA: fullSHA, TreePath: treePath, Line: line, @@ -104,7 +105,7 @@ func CreateCommitComment(ctx *context.Context) { Patch: patch, } - if err := issues_model.CreateCommitComment(ctx, ctx.Repo.Repository.ID, fullSHA, comment); err != nil { + if err := issues_model.CreateCommitComment(ctx, comment); err != nil { ctx.ServerError("CreateCommitComment", err) return } @@ -135,12 +136,13 @@ func DeleteCommitComment(ctx *context.Context) { return } - if comment.PosterID != ctx.Doer.ID && !ctx.Repo.IsAdmin() { - ctx.JSONError("permission denied") + // Allow deletion by the comment author or anyone with write access to code + if comment.PosterID != ctx.Doer.ID && !ctx.Repo.CanWrite(unit_model.TypeCode) { + ctx.HTTPError(http.StatusForbidden) return } - if err := issues_model.DeleteCommitComment(ctx, commentID); err != nil { + if err := issues_model.DeleteCommitComment(ctx, ctx.Repo.Repository.ID, commentID); err != nil { ctx.ServerError("DeleteCommitComment", err) return } diff --git a/routers/web/web.go b/routers/web/web.go index 01cc33814f..f7086f3fbc 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1685,9 +1685,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.RenderNewCommitCommentForm) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, context.RepoMustNotBeArchived(), repo.CreateCommitComment) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) + m.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqRepoCodeWriter, repo.RenderNewCommitCommentForm) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.CreateCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) // 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/templates/repo/diff/commit_comments.tmpl b/templates/repo/diff/commit_comments.tmpl index 64dc7a1763..49763cd442 100644 --- a/templates/repo/diff/commit_comments.tmpl +++ b/templates/repo/diff/commit_comments.tmpl @@ -14,7 +14,7 @@
- {{if and $.root.IsSigned (eq $.root.SignedUserID .PosterID)}} + {{if and $.root.IsSigned (or (eq $.root.SignedUserID .PosterID) $.root.CanWriteCode)}}
{{svg "octicon-trash" 14}}
diff --git a/templates/repo/diff/commit_conversation.tmpl b/templates/repo/diff/commit_conversation.tmpl index 9577091693..3ace4c70ea 100644 --- a/templates/repo/diff/commit_conversation.tmpl +++ b/templates/repo/diff/commit_conversation.tmpl @@ -4,7 +4,7 @@
- {{template "repo/diff/commit_comments" dict "root" $ "comments" .comments}} + {{template "repo/diff/commit_comments" dict "root" $.root "comments" .comments}}
@@ -17,8 +17,8 @@
- {{if and $.SignedUserID (not $.Repository.IsArchived)}} - {{template "repo/diff/commit_comment_form" dict "hidden" true "root" $}} + {{if and $.root.SignedUserID (not $.root.Repository.IsArchived)}} + {{template "repo/diff/commit_comment_form" dict "hidden" true "root" $.root}} {{end}}
diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index f892a817b7..d53a8577e6 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -150,12 +150,12 @@
diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index cda07c5de0..4787143f1d 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -62,10 +62,10 @@ From 72c6313a4b7d164f1605ca38e071341102d3a0c5 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Wed, 11 Mar 2026 00:59:31 +0530 Subject: [PATCH 08/13] fix: use assert.Positive per testifylint --- models/issues/commit_comment_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/issues/commit_comment_test.go b/models/issues/commit_comment_test.go index af2e0e78d7..18c9fab68f 100644 --- a/models/issues/commit_comment_test.go +++ b/models/issues/commit_comment_test.go @@ -28,7 +28,7 @@ func TestCreateCommitComment(t *testing.T) { err := issues_model.CreateCommitComment(t.Context(), comment) require.NoError(t, err) - assert.Greater(t, comment.ID, int64(0)) + assert.Positive(t, comment.ID) assert.Equal(t, issues_model.CommentTypeCommitComment, comment.Type) // Verify it's in the DB From f8fb20aa75549a5283ba1eac7a6952c15a755595 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sat, 14 Mar 2026 15:01:41 +0530 Subject: [PATCH 09/13] fix: address review feedback on permissions and poster loading --- models/issues/commit_comment.go | 6 ++---- routers/web/web.go | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/models/issues/commit_comment.go b/models/issues/commit_comment.go index ef39255714..4356741fd4 100644 --- a/models/issues/commit_comment.go +++ b/models/issues/commit_comment.go @@ -29,10 +29,8 @@ func FindCommitCommentsByCommitSHA(ctx context.Context, repoID int64, commitSHA return nil, err } - for _, c := range comments { - if err := c.LoadPoster(ctx); err != nil { - return nil, err - } + if err := CommentList(comments).LoadPosters(ctx); err != nil { + return nil, err } return comments, nil diff --git a/routers/web/web.go b/routers/web/web.go index f7086f3fbc..01cc33814f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1685,9 +1685,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqRepoCodeWriter, repo.RenderNewCommitCommentForm) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.CreateCommitComment) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, reqRepoCodeWriter, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) + m.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.RenderNewCommitCommentForm) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, context.RepoMustNotBeArchived(), repo.CreateCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) // 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) From 31a8c3665f0354e0377a710a680eb6b3c555814b Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sat, 14 Mar 2026 15:34:50 +0530 Subject: [PATCH 10/13] add notifications for commit comments notify the commit author (resolved by email) and @mentioned users when a new commit comment is created. uses the existing NotificationSourceCommit source and follows the same pattern as CreateRepoTransferNotification. --- models/activities/notification.go | 43 ++++++++++++++++++++++++++++++ routers/web/repo/commit_comment.go | 8 ++++++ 2 files changed, 51 insertions(+) diff --git a/models/activities/notification.go b/models/activities/notification.go index 8a830c5aa2..04c13522ae 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -115,6 +115,49 @@ func init() { db.RegisterModel(new(Notification)) } +// CreateCommitCommentNotification creates notifications for a commit comment. +// It notifies the commit author (if they're a Gitea user) and any @mentioned users. +func CreateCommitCommentNotification(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, comment *issues_model.Comment, commitAuthorEmail string, mentionedUsernames []string) error { + return db.WithTx(ctx, func(ctx context.Context) error { + receiverIDs := make(map[int64]struct{}) + + // Notify the commit author if they map to a Gitea user + if commitAuthorEmail != "" { + author, err := user_model.GetUserByEmail(ctx, commitAuthorEmail) + if err == nil && author.ID != doer.ID { + receiverIDs[author.ID] = struct{}{} + } + } + + // Notify @mentioned users + for _, username := range mentionedUsernames { + mentioned, err := user_model.GetUserByName(ctx, username) + if err == nil && mentioned.ID != doer.ID { + receiverIDs[mentioned.ID] = struct{}{} + } + } + + if len(receiverIDs) == 0 { + return nil + } + + var notifications []*Notification + for uid := range receiverIDs { + notifications = append(notifications, &Notification{ + UserID: uid, + RepoID: repo.ID, + Status: NotificationStatusUnread, + Source: NotificationSourceCommit, + CommitID: comment.CommitSHA, + CommentID: comment.ID, + UpdatedBy: doer.ID, + }) + } + + return db.Insert(ctx, notifications) + }) +} + // CreateRepoTransferNotification creates notification for the user a repository was transferred to func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error { return db.WithTx(ctx, func(ctx context.Context) error { diff --git a/routers/web/repo/commit_comment.go b/routers/web/repo/commit_comment.go index 5b17341597..c33a073d51 100644 --- a/routers/web/repo/commit_comment.go +++ b/routers/web/repo/commit_comment.go @@ -6,12 +6,14 @@ package repo import ( "net/http" + activities_model "code.gitea.io/gitea/models/activities" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" @@ -110,6 +112,12 @@ func CreateCommitComment(ctx *context.Context) { return } + // Send notifications to commit author and @mentioned users + mentions := references.FindAllMentionsMarkdown(content) + if err := activities_model.CreateCommitCommentNotification(ctx, ctx.Doer, ctx.Repo.Repository, comment, commit.Author.Email, mentions); err != nil { + log.Error("CreateCommitCommentNotification: %v", err) + } + // Render markdown content rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{}) comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content) From 83bd55b2d1c006c2a26bbe18f96397e0085165ba Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 15 Mar 2026 01:42:08 +0530 Subject: [PATCH 11/13] use GetUserIDsByNames for mention lookups --- models/activities/notification.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/models/activities/notification.go b/models/activities/notification.go index 04c13522ae..5b4578edfb 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -130,10 +130,14 @@ func CreateCommitCommentNotification(ctx context.Context, doer *user_model.User, } // Notify @mentioned users - for _, username := range mentionedUsernames { - mentioned, err := user_model.GetUserByName(ctx, username) - if err == nil && mentioned.ID != doer.ID { - receiverIDs[mentioned.ID] = struct{}{} + if len(mentionedUsernames) > 0 { + mentionedIDs, err := user_model.GetUserIDsByNames(ctx, mentionedUsernames, true) + if err == nil { + for _, id := range mentionedIDs { + if id != doer.ID { + receiverIDs[id] = struct{}{} + } + } } } From 0c1976c2f3e446e8781f577f4afbe54378bc7687 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 16 Mar 2026 19:31:14 +0530 Subject: [PATCH 12/13] fix: use upstream v326 migration, resolve duplicate declaration --- models/migrations/v1_26/v326.go | 213 ++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 9 deletions(-) diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 5fdd62758f..1ec0af76a0 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -4,18 +4,213 @@ package v1_26 import ( + "fmt" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "xorm.io/xorm" ) -func AddCommitCommentColumns(x *xorm.Engine) error { - // Add RepoID column to the comment table for commit comments. - // Commit comments (type=39) store repo_id directly on the comment - // instead of deriving it through issue_id, since they don't belong - // to any issue. Combined with the existing commit_sha column, this - // allows querying commit comments without a junction table. - type Comment struct { - RepoID int64 `xorm:"INDEX DEFAULT 0"` +const actionsRunPath = "/actions/runs/" + +type migrationRepository struct { + ID int64 + OwnerName string + Name string +} + +type migrationActionRun struct { + ID int64 + RepoID int64 + Index int64 +} + +type migrationActionRunJob struct { + ID int64 + RunID int64 +} + +type migrationCommitStatus struct { + ID int64 + RepoID int64 + TargetURL string +} + +func FixCommitStatusTargetURLToUseRunAndJobID(x *xorm.Engine) error { + runByIndexCache := make(map[int64]map[int64]*migrationActionRun) + jobsByRunIDCache := make(map[int64][]int64) + repoLinkCache := make(map[int64]string) + + if err := migrateCommitStatusTargetURL(x, "commit_status", runByIndexCache, jobsByRunIDCache, repoLinkCache); err != nil { + return err + } + return migrateCommitStatusTargetURL(x, "commit_status_summary", runByIndexCache, jobsByRunIDCache, repoLinkCache) +} + +func migrateCommitStatusTargetURL( + x *xorm.Engine, + table string, + runByIndexCache map[int64]map[int64]*migrationActionRun, + jobsByRunIDCache map[int64][]int64, + repoLinkCache map[int64]string, +) error { + const batchSize = 500 + var lastID int64 + + for { + var rows []migrationCommitStatus + sess := x.Table(table). + Where("target_url LIKE ?", "%"+actionsRunPath+"%"). + And("id > ?", lastID). + Asc("id"). + Limit(batchSize) + if err := sess.Find(&rows); err != nil { + return fmt.Errorf("query %s: %w", table, err) + } + if len(rows) == 0 { + return nil + } + + for _, row := range rows { + lastID = row.ID + if row.TargetURL == "" { + continue + } + + repoLink, err := getRepoLinkCached(x, repoLinkCache, row.RepoID) + if err != nil || repoLink == "" { + if err != nil { + log.Warn("convert %s id=%d getRepoLinkCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: repo=%d not found", table, row.ID, row.RepoID) + } + continue + } + + runNum, jobNum, ok := parseTargetURL(row.TargetURL, repoLink) + if !ok { + continue + } + + run, err := getRunByIndexCached(x, runByIndexCache, row.RepoID, runNum) + if err != nil || run == nil { + if err != nil { + log.Warn("convert %s id=%d getRunByIndexCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: run not found for repo_id=%d run_index=%d", table, row.ID, row.RepoID, runNum) + } + continue + } + + jobID, ok, err := getJobIDByIndexCached(x, jobsByRunIDCache, run.ID, jobNum) + if err != nil || !ok { + if err != nil { + log.Warn("convert %s id=%d getJobIDByIndexCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: job not found for run_id=%d job_index=%d", table, row.ID, run.ID, jobNum) + } + continue + } + + oldURL := row.TargetURL + newURL := fmt.Sprintf("%s%s%d/jobs/%d", repoLink, actionsRunPath, run.ID, jobID) // expect: {repo_link}/actions/runs/{run_id}/jobs/{job_id} + if oldURL == newURL { + continue + } + + if _, err := x.Table(table).ID(row.ID).Cols("target_url").Update(&migrationCommitStatus{TargetURL: newURL}); err != nil { + return fmt.Errorf("update %s id=%d target_url from %s to %s: %w", table, row.ID, oldURL, newURL, err) + } + } + } +} + +func getRepoLinkCached(x *xorm.Engine, cache map[int64]string, repoID int64) (string, error) { + if link, ok := cache[repoID]; ok { + return link, nil + } + repo := &migrationRepository{} + has, err := x.Table("repository").Where("id=?", repoID).Get(repo) + if err != nil { + return "", err + } + if !has { + cache[repoID] = "" + return "", nil + } + link := setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) + cache[repoID] = link + return link, nil +} + +func getRunByIndexCached(x *xorm.Engine, cache map[int64]map[int64]*migrationActionRun, repoID, runIndex int64) (*migrationActionRun, error) { + if repoCache, ok := cache[repoID]; ok { + if run, ok := repoCache[runIndex]; ok { + if run == nil { + return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) + } + return run, nil + } } - return x.Sync(new(Comment)) + var run migrationActionRun + has, err := x.Table("action_run").Where("repo_id=? AND `index`=?", repoID, runIndex).Get(&run) + if err != nil { + return nil, err + } + if !has { + if cache[repoID] == nil { + cache[repoID] = make(map[int64]*migrationActionRun) + } + cache[repoID][runIndex] = nil + return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) + } + if cache[repoID] == nil { + cache[repoID] = make(map[int64]*migrationActionRun) + } + cache[repoID][runIndex] = &run + return &run, nil +} + +func getJobIDByIndexCached(x *xorm.Engine, cache map[int64][]int64, runID, jobIndex int64) (int64, bool, error) { + jobIDs, ok := cache[runID] + if !ok { + var jobs []migrationActionRunJob + if err := x.Table("action_run_job").Where("run_id=?", runID).Asc("id").Cols("id").Find(&jobs); err != nil { + return 0, false, err + } + jobIDs = make([]int64, 0, len(jobs)) + for _, job := range jobs { + jobIDs = append(jobIDs, job.ID) + } + cache[runID] = jobIDs + } + if jobIndex < 0 || jobIndex >= int64(len(jobIDs)) { + return 0, false, nil + } + return jobIDs[jobIndex], true, nil +} + +func parseTargetURL(targetURL, repoLink string) (runNum, jobNum int64, ok bool) { + prefix := repoLink + actionsRunPath + if !strings.HasPrefix(targetURL, prefix) { + return 0, 0, false + } + rest := targetURL[len(prefix):] + + parts := strings.Split(rest, "/") // expect: {run_num}/jobs/{job_num} + if len(parts) == 3 && parts[1] == "jobs" { + runNum, err1 := strconv.ParseInt(parts[0], 10, 64) + jobNum, err2 := strconv.ParseInt(parts[2], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runNum, jobNum, true + } + + return 0, 0, false } From 926b3aec4eb434760913c6319ececdc58da4f69a Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 29 Mar 2026 13:48:21 +0530 Subject: [PATCH 13/13] fix: add reqUnitCodeReader to commit comment routes per silverwind's review --- routers/web/web.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 01cc33814f..3463250ad5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1685,9 +1685,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { 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.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, repo.RenderNewCommitCommentForm) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, context.RepoMustNotBeArchived(), repo.CreateCommitComment) - m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) + m.Get("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqUnitCodeReader, repo.RenderNewCommitCommentForm) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment", reqSignIn, reqUnitCodeReader, context.RepoMustNotBeArchived(), repo.CreateCommitComment) + m.Post("/commit/{sha:([a-f0-9]{7,64})$}/comment/{id}/delete", reqSignIn, reqUnitCodeReader, context.RepoMustNotBeArchived(), repo.DeleteCommitComment) // 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)
{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}} @@ -43,7 +46,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.PageIsDiff) -}} @@ -60,7 +63,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.PageIsDiff) (not (eq .GetType 2)) -}} @@ -75,7 +78,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.PageIsDiff) (not (eq .GetType 3)) -}} @@ -132,6 +135,32 @@
+ {{if $leftCC}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}} + {{end}} + + {{if $rightCC}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}} + {{end}} +
{{template "repo/diff/section_code" dict "diff" $inlineDiff}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}} @@ -49,5 +52,24 @@
+ {{if $rightCC}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}} + {{end}} + {{if $leftCC}} + {{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}} + {{end}} +
{{if $leftCC}} - {{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}} + {{template "repo/diff/commit_conversation" dict "root" $.root "comments" $leftCC}} {{end}} {{if $rightCC}} - {{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}} + {{template "repo/diff/commit_conversation" dict "root" $.root "comments" $rightCC}} {{end}}
{{if $rightCC}} - {{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}} + {{template "repo/diff/commit_conversation" dict "root" $.root "comments" $rightCC}} {{end}} {{if $leftCC}} - {{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}} + {{template "repo/diff/commit_conversation" dict "root" $.root "comments" $leftCC}} {{end}}