diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index db74ff78d5..d155066e4a 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 commit comment table", v1_26.AddCommitCommentTable), } return preparedMigrations } diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 76532e2f85..387a183bbc 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -4,312 +4,24 @@ package v1_26 import ( - "errors" - "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" + "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"` + } - // Only commit status target URLs whose resolved run ID is smaller than this threshold are rewritten by this partial migration. - // 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 + return x.Sync(new(CommitComment)) } diff --git a/models/migrations/v1_26/v331.go b/models/migrations/v1_26/v331.go new file mode 100644 index 0000000000..387a183bbc --- /dev/null +++ b/models/migrations/v1_26/v331.go @@ -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)) +} 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 61d1fdc142..859ae7d90d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1690,6 +1690,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 c13b205518..401bdea6af 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 4dee648cdd..278c465838 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}} @@ -31,7 +34,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}}
{{if $line.LeftIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $leftDiff.EscapeStatus}}{{end}} - {{- if and $.root.SignedUserID $.root.PageIsPullFiles -}} + {{- if and $.root.SignedUserID (or $.root.PageIsPullFiles $.root.PageIsDiff) -}} @@ -43,7 +46,7 @@ {{if $match.RightIdx}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $rightDiff.EscapeStatus}}{{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}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{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}}{{ctx.RenderUtils.RenderUnicodeEscapeToggleButton $inlineDiff.EscapeStatus}}{{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) -}} @@ -47,5 +50,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}} +