Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)

newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Add commit comment table", v1_26.AddCommitCommentTable),
}
return preparedMigrations
}
Expand Down
29 changes: 29 additions & 0 deletions models/migrations/v1_26/v324.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import (
"code.gitea.io/gitea/modules/timeutil"

"xorm.io/xorm"
)

func AddCommitCommentTable(x *xorm.Engine) error {
type CommitComment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
TreePath string `xorm:"VARCHAR(4000)"`
Line int64
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
PosterID int64 `xorm:"INDEX"`
OriginalAuthor string
OriginalAuthorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}

return x.Sync2(new(CommitComment))
}
194 changes: 194 additions & 0 deletions models/repo/commit_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"context"
"fmt"
"html/template"

"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil"

"xorm.io/builder"
)

// CommitComment represents a comment on a specific line in a commit diff
type CommitComment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can have a reply_id?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not only one or two fields, the table's design overall doesn't seem right. I have explained in discord.

Repo *Repository `xorm:"-"`
CommitSHA string `xorm:"VARCHAR(64) INDEX"`
TreePath string `xorm:"VARCHAR(4000)"` // File path (same field name as issue comments)
Line int64 // - previous line / + proposed line
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
RenderedContent template.HTML `xorm:"-"`
PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"`
OriginalAuthor string
OriginalAuthorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
Attachments []*Attachment `xorm:"-"`

// Fields for template compatibility with PR comments
Review any `xorm:"-"` // Always nil for commit comments
Invalidated bool `xorm:"-"` // Always false for commit comments
ResolveDoer any `xorm:"-"` // Always nil for commit comments
Reactions any `xorm:"-"` // Reactions for this comment
}

// IsResolved returns false (commit comments don't support resolution)
func (c *CommitComment) IsResolved() bool {
return false
}

// HasOriginalAuthor returns if a comment was migrated and has an original author
func (c *CommitComment) HasOriginalAuthor() bool {
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
}

func init() {
db.RegisterModel(new(CommitComment))
}

// ErrCommitCommentNotExist represents a "CommitCommentNotExist" kind of error.
type ErrCommitCommentNotExist struct {
ID int64
}

// IsErrCommitCommentNotExist checks if an error is a ErrCommitCommentNotExist.
func IsErrCommitCommentNotExist(err error) bool {
_, ok := err.(ErrCommitCommentNotExist)
return ok
}

func (err ErrCommitCommentNotExist) Error() string {
return fmt.Sprintf("commit comment does not exist [id: %d]", err.ID)
}

// CreateCommitComment creates a new commit comment
func CreateCommitComment(ctx context.Context, comment *CommitComment) error {
return db.Insert(ctx, comment)
}

// GetCommitCommentByID returns a commit comment by ID
func GetCommitCommentByID(ctx context.Context, id int64) (*CommitComment, error) {
comment := new(CommitComment)
has, err := db.GetEngine(ctx).ID(id).Get(comment)
if err != nil {
return nil, err
} else if !has {
return nil, ErrCommitCommentNotExist{id}
}
return comment, nil
}

// FindCommitCommentsOptions describes the conditions to find commit comments
type FindCommitCommentsOptions struct {
db.ListOptions
RepoID int64
CommitSHA string
Path string
Line int64
}

// ToConds implements FindOptions interface
func (opts FindCommitCommentsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
}
if opts.Path != "" {
cond = cond.And(builder.Eq{"tree_path": opts.Path})
}
if opts.Line != 0 {
cond = cond.And(builder.Eq{"line": opts.Line})
}
return cond
}

// FindCommitComments returns commit comments based on options
func FindCommitComments(ctx context.Context, opts FindCommitCommentsOptions) ([]*CommitComment, error) {
comments := make([]*CommitComment, 0, 10)
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, &opts)
}
return comments, sess.Find(&comments)
}

// LoadPoster loads the poster user
func (c *CommitComment) LoadPoster(ctx context.Context) error {
if c.Poster != nil {
return nil
}
var err error
c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
c.PosterID = user_model.GhostUserID
c.Poster = user_model.NewGhostUser()
}
}
return err
}

// LoadRepo loads the repository
func (c *CommitComment) LoadRepo(ctx context.Context) error {
if c.Repo != nil {
return nil
}
var err error
c.Repo, err = GetRepositoryByID(ctx, c.RepoID)
return err
}

// LoadAttachments loads attachments
func (c *CommitComment) LoadAttachments(ctx context.Context) error {
if len(c.Attachments) > 0 {
return nil
}
var err error
c.Attachments, err = GetAttachmentsByCommentID(ctx, c.ID)
return err
}

// DiffSide returns "previous" if Line is negative and "proposed" if positive
func (c *CommitComment) DiffSide() string {
if c.Line < 0 {
return "previous"
}
return "proposed"
}

// UnsignedLine returns the absolute value of the line number
func (c *CommitComment) UnsignedLine() uint64 {
if c.Line < 0 {
return uint64(c.Line * -1)
}
return uint64(c.Line)
}

// HashTag returns unique hash tag for comment
func (c *CommitComment) HashTag() string {
return fmt.Sprintf("commitcomment-%d", c.ID)
}

// UpdateCommitComment updates a commit comment
func UpdateCommitComment(ctx context.Context, comment *CommitComment) error {
_, err := db.GetEngine(ctx).ID(comment.ID).AllCols().Update(comment)
return err
}

// DeleteCommitComment deletes a commit comment
func DeleteCommitComment(ctx context.Context, comment *CommitComment) error {
_, err := db.GetEngine(ctx).ID(comment.ID).Delete(comment)
return err
}
111 changes: 111 additions & 0 deletions routers/web/repo/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"code.gitea.io/gitea/services/gitdiff"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/services/repository/gitgraph"
user_service "code.gitea.io/gitea/services/user"
)

const (
Expand Down Expand Up @@ -272,6 +273,16 @@ func LoadBranchesAndTags(ctx *context.Context) {
// Diff show different from current commit to previous commit
func Diff(ctx *context.Context) {
ctx.Data["PageIsDiff"] = true
ctx.Data["PageIsCommitDiff"] = true // Enable comment buttons on commit diffs

// Set up user blocking function for comments (only if signed in)
if ctx.IsSigned {
ctx.Data["SignedUserID"] = ctx.Doer.ID
ctx.Data["SignedUser"] = ctx.Doer
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
}

userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name
Expand Down Expand Up @@ -363,6 +374,13 @@ func Diff(ctx *context.Context) {
setCompareContext(ctx, parentCommit, commit, userName, repoName)
ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID)
ctx.Data["Commit"] = commit

// Load commit comments into the diff
if err := loadCommitCommentsIntoDiff(ctx, diff, commitID); err != nil {
ctx.ServerError("loadCommitCommentsIntoDiff", err)
return
}

ctx.Data["Diff"] = diff
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData

Expand Down Expand Up @@ -427,6 +445,99 @@ func Diff(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplCommitPage)
}

// loadCommitCommentsIntoDiff loads commit comments and attaches them to diff lines
func loadCommitCommentsIntoDiff(ctx *context.Context, diff *gitdiff.Diff, commitSHA string) error {
comments, err := repo_model.FindCommitComments(ctx, repo_model.FindCommitCommentsOptions{
RepoID: ctx.Repo.Repository.ID,
CommitSHA: commitSHA,
})
if err != nil {
return err
}

// Load posters, attachments, reactions, and render comments
for _, comment := range comments {
if err := comment.LoadPoster(ctx); err != nil {
return err
}
if err := comment.LoadAttachments(ctx); err != nil {
return err
}
if err := repo_service.RenderCommitComment(ctx, comment); err != nil {
return err
}
// Load reactions for this comment
reactions, _, err := issues_model.FindCommentReactions(ctx, 0, comment.ID)
if err != nil {
return err
}
if _, err := reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
return err
}
comment.Reactions = reactions
}

// Group comments by file and line number
allComments := make(map[string]map[int64][]*repo_model.CommitComment)
for _, comment := range comments {
if allComments[comment.TreePath] == nil {
allComments[comment.TreePath] = make(map[int64][]*repo_model.CommitComment)
}
allComments[comment.TreePath][comment.Line] = append(allComments[comment.TreePath][comment.Line], comment)
}

// Attach comments to diff lines
for _, file := range diff.Files {
if lineComments, ok := allComments[file.Name]; ok {
for _, section := range file.Sections {
for _, line := range section.Lines {
// Check for comments on the left side (previous/old)
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
}
// Check for comments on the right side (proposed/new)
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, convertCommitCommentsToIssueComments(comments)...)
}
}
}
}
}

return nil
}

// convertCommitCommentsToIssueComments converts CommitComment to Comment interface for template compatibility
func convertCommitCommentsToIssueComments(commitComments []*repo_model.CommitComment) []*issues_model.Comment {
comments := make([]*issues_model.Comment, len(commitComments))
for i, cc := range commitComments {
var reactions issues_model.ReactionList
if cc.Reactions != nil {
if r, ok := cc.Reactions.(issues_model.ReactionList); ok {
reactions = r
}
}
// Create a minimal Comment struct that the template can use
comments[i] = &issues_model.Comment{
ID: cc.ID,
PosterID: cc.PosterID,
Poster: cc.Poster,
OriginalAuthor: cc.OriginalAuthor,
OriginalAuthorID: cc.OriginalAuthorID,
TreePath: cc.TreePath,
Line: cc.Line,
Content: cc.Content,
ContentVersion: cc.ContentVersion,
RenderedContent: cc.RenderedContent,
CreatedUnix: cc.CreatedUnix,
UpdatedUnix: cc.UpdatedUnix,
Reactions: reactions,
Attachments: cc.Attachments,
}
}
return comments
}

// RawDiff dumps diff results of repository in given commit ID to io.Writer
func RawDiff(ctx *context.Context) {
var gitRepo *git.Repository
Expand Down
Loading