0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-13 17:35:18 +02:00

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
This commit is contained in:
yuvrajangadsingh 2026-03-08 14:55:29 +05:30
parent ba9258c478
commit f492c92d6d
No known key found for this signature in database
14 changed files with 534 additions and 309 deletions

View File

@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration {
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook), newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
newMigration(331, "Add commit comment table", v1_26.AddCommitCommentTable),
} }
return preparedMigrations return preparedMigrations
} }

View File

@ -4,312 +4,24 @@
package v1_26 package v1_26
import ( import (
"errors" "code.gitea.io/gitea/modules/timeutil"
"fmt"
"net/url"
"strconv"
"strings"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
"xorm.io/xorm" "xorm.io/xorm"
) )
const ( func AddCommitCommentTable(x *xorm.Engine) error {
actionsRunPath = "/actions/runs/" 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"`
}
// Only commit status target URLs whose resolved run ID is smaller than this threshold are rewritten by this partial migration. return x.Sync(new(CommitComment))
// The fixed value 1000 is a conservative cutoff chosen to cover the smaller legacy run indexes that are most likely to be confused with ID-based URLs at runtime.
// Larger legacy {run} or {job} numbers are usually easier to disambiguate. For example:
// * /actions/runs/1200/jobs/1420 is most likely an ID-based URL, because a run should not contain more than 256 jobs.
// * /actions/runs/1500/jobs/3 is most likely an index-based URL, because a job ID cannot be smaller than its run ID.
// But URLs with small numbers, such as /actions/runs/5/jobs/6, are much harder to distinguish reliably.
// This migration therefore prioritizes rewriting target URLs for runs in that lower range.
legacyURLIDThreshold int64 = 1000
)
type migrationRepository struct {
ID int64
OwnerName string
Name string
}
type migrationActionRun struct {
ID int64
RepoID int64
Index int64
CommitSHA string `xorm:"commit_sha"`
Event webhook_module.HookEventType
TriggerEvent string
EventPayload string
}
type migrationActionRunJob struct {
ID int64
RunID int64
}
type migrationCommitStatus struct {
ID int64
RepoID int64
TargetURL string
}
type commitSHAAndRuns struct {
commitSHA string
runs map[int64]*migrationActionRun
}
// FixCommitStatusTargetURLToUseRunAndJobID partially migrates legacy Actions
// commit status target URLs to the new run/job ID-based form.
//
// Only rows whose resolved run ID is below legacyURLIDThreshold are rewritten.
// This is because smaller legacy run indexes are more likely to collide with run ID URLs during runtime resolution,
// so this migration prioritizes that lower range and leaves the remaining legacy target URLs to the web compatibility logic.
func FixCommitStatusTargetURLToUseRunAndJobID(x *xorm.Engine) error {
jobsByRunIDCache := make(map[int64][]int64)
repoLinkCache := make(map[int64]string)
groups, err := loadLegacyMigrationRunGroups(x)
if err != nil {
return err
}
for repoID, groupsBySHA := range groups {
for _, group := range groupsBySHA {
if err := migrateCommitStatusTargetURLForGroup(x, "commit_status", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
return err
}
if err := migrateCommitStatusTargetURLForGroup(x, "commit_status_summary", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
return err
}
}
}
return nil
}
func loadLegacyMigrationRunGroups(x *xorm.Engine) (map[int64]map[string]*commitSHAAndRuns, error) {
var runs []migrationActionRun
if err := x.Table("action_run").
Where("id < ?", legacyURLIDThreshold).
Cols("id", "repo_id", "`index`", "commit_sha", "event", "trigger_event", "event_payload").
Find(&runs); err != nil {
return nil, fmt.Errorf("query action_run: %w", err)
}
groups := make(map[int64]map[string]*commitSHAAndRuns)
for i := range runs {
run := runs[i]
commitID, err := getCommitStatusCommitID(&run)
if err != nil {
log.Warn("skip action_run id=%d when resolving commit status commit SHA: %v", run.ID, err)
continue
}
if commitID == "" {
// empty commitID means the run didn't create any commit status records, just skip
continue
}
if groups[run.RepoID] == nil {
groups[run.RepoID] = make(map[string]*commitSHAAndRuns)
}
if groups[run.RepoID][commitID] == nil {
groups[run.RepoID][commitID] = &commitSHAAndRuns{
commitSHA: commitID,
runs: make(map[int64]*migrationActionRun),
}
}
groups[run.RepoID][commitID].runs[run.Index] = &run
}
return groups, nil
}
func migrateCommitStatusTargetURLForGroup(
x *xorm.Engine,
table string,
repoID int64,
sha string,
runs map[int64]*migrationActionRun,
jobsByRunIDCache map[int64][]int64,
repoLinkCache map[int64]string,
) error {
var rows []migrationCommitStatus
if err := x.Table(table).
Where("repo_id = ?", repoID).
And("sha = ?", sha).
Cols("id", "repo_id", "target_url").
Find(&rows); err != nil {
return fmt.Errorf("query %s for repo_id=%d sha=%s: %w", table, repoID, sha, err)
}
for _, row := range rows {
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, ok := runs[runNum]
if !ok {
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)
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)
}
}
return nil
}
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 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, "/")
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
}
func getCommitStatusCommitID(run *migrationActionRun) (string, error) {
switch run.Event {
case webhook_module.HookEventPush:
payload, err := getPushEventPayload(run)
if err != nil {
return "", fmt.Errorf("getPushEventPayload: %w", err)
}
if payload.HeadCommit == nil {
return "", errors.New("head commit is missing in event payload")
}
return payload.HeadCommit.ID, nil
case webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestReviewRequest,
webhook_module.HookEventPullRequestMilestone:
payload, err := getPullRequestEventPayload(run)
if err != nil {
return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
}
if payload.PullRequest == nil {
return "", errors.New("pull request is missing in event payload")
} else if payload.PullRequest.Head == nil {
return "", errors.New("head of pull request is missing in event payload")
}
return payload.PullRequest.Head.Sha, nil
case webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected,
webhook_module.HookEventPullRequestReviewComment:
payload, err := getPullRequestEventPayload(run)
if err != nil {
return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
}
if payload.PullRequest == nil {
return "", errors.New("pull request is missing in event payload")
} else if payload.PullRequest.Head == nil {
return "", errors.New("head of pull request is missing in event payload")
}
return payload.PullRequest.Head.Sha, nil
case webhook_module.HookEventRelease:
return run.CommitSHA, nil
default:
return "", nil
}
}
func getPushEventPayload(run *migrationActionRun) (*api.PushPayload, error) {
if run.Event != webhook_module.HookEventPush {
return nil, fmt.Errorf("event %s is not a push event", run.Event)
}
var payload api.PushPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
return &payload, nil
}
func getPullRequestEventPayload(run *migrationActionRun) (*api.PullRequestPayload, error) {
if !run.Event.IsPullRequest() && !run.Event.IsPullRequestReview() {
return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
}
var payload api.PullRequestPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
return &payload, nil
} }

View File

@ -0,0 +1,27 @@
// Copyright 2026 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 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"`
}
return x.Sync(new(CommitComment))
}

View File

@ -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{})
}

View File

@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -424,6 +425,35 @@ func Diff(ctx *context.Context) {
ctx.Data["MergedPRIssueNumber"] = pr.Index 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) ctx.HTML(http.StatusOK, tplCommitPage)
} }

View File

@ -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})
}

View File

@ -1690,6 +1690,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/graph", repo.Graph) 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})$}", 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})$}/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 // 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) m.Get("/cherry-pick/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, context.RepoRefByDefaultBranch(), repo.CherryPick)

View File

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

View File

@ -0,0 +1,25 @@
{{if and ctx.RootData.SignedUserID (not ctx.RootData.Repository.IsArchived)}}
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{$.root.RepoLink}}/commit/{{$.root.CommitID}}/comment" method="post">
<input type="hidden" name="side" value="">
<input type="hidden" name="line" value="">
<input type="hidden" name="tree_path" value="">
<div class="field">
{{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"
)}}
</div>
<div class="field footer">
<div class="flex-text-block tw-justify-end">
<button type="submit" class="ui submit primary tiny button btn-add-single">{{ctx.Locale.Tr "repo.diff.comment.add_single_comment"}}</button>
{{if or (not $.HasComments) $.hidden}}
<button type="button" class="ui submit tiny basic button btn-cancel cancel-code-comment">{{ctx.Locale.Tr "cancel"}}</button>
{{end}}
</div>
</div>
</form>
{{end}}

View File

@ -0,0 +1,35 @@
{{range .comments}}
{{$createdStr := DateUtils.TimeSince .CreatedUnix}}
<div class="comment" id="{{.HashTag}}">
<div class="tw-mt-2 tw-mr-4">
{{template "shared/user/avatarlink" dict "user" .Poster}}
</div>
<div class="content comment-container">
<div class="comment-header avatar-content-left-arrow">
<div class="comment-header-left">
<span class="tw-text-text-light muted-links">
{{template "shared/user/namelink" .Poster}}
{{ctx.Locale.Tr "repo.issues.commented_at" .HashTag $createdStr}}
</span>
</div>
<div class="comment-header-right">
{{if and $.root.IsSigned (eq $.root.SignedUserID .PosterID)}}
<div class="item context js-aria-clickable delete-comment" data-comment-id="{{.HashTag}}" data-url="{{$.root.RepoLink}}/commit/{{.CommitSHA}}/comment/{{.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">
{{svg "octicon-trash" 14}}
</div>
{{end}}
</div>
</div>
<div class="ui attached segment comment-body">
<div class="render-content markup">
{{if .RenderedContent}}
{{.RenderedContent}}
{{else}}
<span class="no-content">{{ctx.Locale.Tr "repo.issues.no_content"}}</span>
{{end}}
</div>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,25 @@
{{if .comments}}
{{$comment := index .comments 0}}
<div class="conversation-holder" data-path="{{$comment.TreePath}}" data-side="{{if eq $comment.GetCommentSide "previous"}}left{{else}}right{{end}}" data-idx="{{$comment.UnsignedLine}}">
<div id="code-comments-{{$comment.ID}}" class="field comment-code-cloud">
<div class="comment-list">
<div class="ui comments">
{{template "repo/diff/commit_comments" dict "root" $ "comments" .comments}}
</div>
</div>
<div class="flex-text-block tw-mt-2 tw-flex-wrap tw-justify-end">
<div class="ui buttons">
<button class="ui icon tiny basic button previous-conversation">
{{svg "octicon-arrow-up" 12}} {{ctx.Locale.Tr "repo.issues.previous"}}
</button>
<button class="ui icon tiny basic button next-conversation">
{{svg "octicon-arrow-down" 12}} {{ctx.Locale.Tr "repo.issues.next"}}
</button>
</div>
</div>
{{if and $.SignedUserID (not $.Repository.IsArchived)}}
{{template "repo/diff/commit_comment_form" dict "hidden" true "root" $}}
{{end}}
</div>
</div>
{{end}}

View File

@ -0,0 +1,5 @@
<div class="conversation-holder">
<div class="field comment-code-cloud">
{{template "repo/diff/commit_comment_form" dict "root" $}}
</div>
</div>

View File

@ -1,5 +1,8 @@
{{$file := .file}} {{$file := .file}}
{{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} {{$diffBlobExcerptData := $.root.DiffBlobExcerptData}}
{{$commitComments := $.root.CommitComments}}
{{$ccFile := ""}}
{{if $commitComments}}{{$ccFile = index $commitComments $file.Name}}{{end}}
<colgroup> <colgroup>
<col width="50"> <col width="50">
<col width="10"> <col width="10">
@ -28,7 +31,7 @@
<td class="lines-escape del-code lines-escape-old">{{if $line.LeftIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $leftDiff.EscapeStatus}}{{end}}</td> <td class="lines-escape del-code lines-escape-old">{{if $line.LeftIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $leftDiff.EscapeStatus}}{{end}}</td>
<td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td> <td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
<td class="lines-code lines-code-old del-code"> <td class="lines-code lines-code-old del-code">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}"> <button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
{{- svg "octicon-plus" -}} {{- svg "octicon-plus" -}}
</button> </button>
@ -43,7 +46,7 @@
<td class="lines-escape add-code lines-escape-new">{{if $match.RightIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $rightDiff.EscapeStatus}}{{end}}</td> <td class="lines-escape add-code lines-escape-new">{{if $match.RightIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $rightDiff.EscapeStatus}}{{end}}</td>
<td class="lines-type-marker lines-type-marker-new add-code">{{if $match.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$match.GetLineTypeMarker}}"></span>{{end}}</td> <td class="lines-type-marker lines-type-marker-new add-code">{{if $match.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$match.GetLineTypeMarker}}"></span>{{end}}</td>
<td class="lines-code lines-code-new add-code"> <td class="lines-code lines-code-new add-code">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $match.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$match.RightIdx}}"> <button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $match.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$match.RightIdx}}">
{{- svg "octicon-plus" -}} {{- svg "octicon-plus" -}}
</button> </button>
@ -60,7 +63,7 @@
<td class="lines-escape lines-escape-old">{{if $line.LeftIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{end}}</td> <td class="lines-escape lines-escape-old">{{if $line.LeftIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{end}}</td>
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td> <td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
<td class="lines-code lines-code-old"> <td class="lines-code lines-code-old">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 2)) -}} {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) (not (eq .GetType 2)) -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}"> <button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-left{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="left" data-idx="{{$line.LeftIdx}}">
{{- svg "octicon-plus" -}} {{- svg "octicon-plus" -}}
</button> </button>
@ -75,7 +78,7 @@
<td class="lines-escape lines-escape-new">{{if $line.RightIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{end}}</td> <td class="lines-escape lines-escape-new">{{if $line.RightIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{end}}</td>
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td> <td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
<td class="lines-code lines-code-new"> <td class="lines-code lines-code-new">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles (not (eq .GetType 3)) -}} {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) (not (eq .GetType 3)) -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$line.RightIdx}}"> <button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-right{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="right" data-idx="{{$line.RightIdx}}">
{{- svg "octicon-plus" -}} {{- svg "octicon-plus" -}}
</button> </button>
@ -132,6 +135,32 @@
</td> </td>
</tr> </tr>
{{end}} {{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}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left" colspan="4">
{{if $leftCC}}
{{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}}
{{end}}
</td>
<td class="add-comment-right" colspan="4">
{{if $rightCC}}
{{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}}
{{end}}
</td>
</tr>
{{end}}
{{end}}
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}

View File

@ -1,6 +1,9 @@
{{$file := .file}} {{$file := .file}}
{{/* this tmpl is also used by the PR Conversation page, so "DiffBlobExcerptData" may not exist */}} {{/* this tmpl is also used by the PR Conversation page, so "DiffBlobExcerptData" may not exist */}}
{{$diffBlobExcerptData := $.root.DiffBlobExcerptData}} {{$diffBlobExcerptData := $.root.DiffBlobExcerptData}}
{{$commitComments := $.root.CommitComments}}
{{$ccFile := ""}}
{{if $commitComments}}{{$ccFile = index $commitComments $file.Name}}{{end}}
<colgroup> <colgroup>
<col width="50"> <col width="50">
<col width="50"> <col width="50">
@ -31,7 +34,7 @@
<td class="chroma lines-code blob-hunk">{{template "repo/diff/section_code" dict "diff" $inlineDiff}}</td> <td class="chroma lines-code blob-hunk">{{template "repo/diff/section_code" dict "diff" $inlineDiff}}</td>
{{else}} {{else}}
<td class="chroma lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}"> <td class="chroma lines-code{{if (not $line.RightIdx)}} lines-code-old{{end}}">
{{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}}
<button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}"> <button type="button" aria-label="{{ctx.Locale.Tr "repo.diff.comment.add_line_comment"}}" class="ui primary button add-code-comment add-code-comment-{{if $line.RightIdx}}right{{else}}left{{end}}{{if (not $line.CanComment)}} tw-invisible{{end}}" data-side="{{if $line.RightIdx}}right{{else}}left{{end}}" data-idx="{{if $line.RightIdx}}{{$line.RightIdx}}{{else}}{{$line.LeftIdx}}{{end}}">
{{- svg "octicon-plus" -}} {{- svg "octicon-plus" -}}
</button> </button>
@ -47,5 +50,24 @@
</td> </td>
</tr> </tr>
{{end}} {{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}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left add-comment-right" colspan="5">
{{if $rightCC}}
{{template "repo/diff/commit_conversation" dict "." $.root "comments" $rightCC}}
{{end}}
{{if $leftCC}}
{{template "repo/diff/commit_conversation" dict "." $.root "comments" $leftCC}}
{{end}}
</td>
</tr>
{{end}}
{{end}}
{{end}} {{end}}
{{end}} {{end}}