From 10461b2f4e4539f3088a3c90a091457b2f117abf Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 7 May 2026 21:53:01 +0200 Subject: [PATCH] feat(repo): display co-author stacks beside commit SHAs Show co-author avatar stacks on commit SHA surfaces including commits list rows, graph, blame, and dashboard feeds, with repository-level data propagation and tests. Co-Authored-By: Claude Opus 4.7 --- modules/repository/commits.go | 2 + modules/repository/commits_test.go | 8 +++- routers/web/repo/blame.go | 18 ++++---- services/repository/gitgraph/graph_models.go | 46 ++++++++++++++++---- templates/repo/blame.tmpl | 2 +- templates/repo/commit_sign_badge.tmpl | 4 +- templates/repo/commits_list_small.tmpl | 6 +-- templates/repo/graph/commits.tmpl | 9 +--- templates/user/dashboard/feeds.tmpl | 2 +- 9 files changed, 60 insertions(+), 37 deletions(-) diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 32550a9f03..546e881090 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -29,6 +29,7 @@ type PushCommit struct { AuthorName string CommitterEmail string CommitterName string + CoAuthors []*git.Signature Timestamp time.Time } @@ -157,6 +158,7 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit { AuthorName: commit.Author.Name, CommitterEmail: commit.Committer.Email, CommitterName: commit.Committer.Name, + CoAuthors: commit.CoAuthorSignatures(), Timestamp: commit.Author.When, } } diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 46db7b028b..a4c0eea8b5 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -145,14 +145,18 @@ func TestCommitToPushCommit(t *testing.T) { ID: sha1, Author: sig, Committer: sig, - CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"}, + CommitMessage: git.CommitMessage{MessageRaw: "Commit Message\n\nCo-authored-by: Jane Doe "}, }) assert.Equal(t, hexString, pushCommit.Sha1) - assert.Equal(t, "Commit Message", pushCommit.Message) + assert.Equal(t, "Commit Message\n\nCo-authored-by: Jane Doe ", pushCommit.Message) assert.Equal(t, "example@example.com", pushCommit.AuthorEmail) assert.Equal(t, "John Doe", pushCommit.AuthorName) assert.Equal(t, "example@example.com", pushCommit.CommitterEmail) assert.Equal(t, "John Doe", pushCommit.CommitterName) + if assert.Len(t, pushCommit.CoAuthors, 1) { + assert.Equal(t, "jane@example.com", pushCommit.CoAuthors[0].Email) + assert.Equal(t, "Jane Doe", pushCommit.CoAuthors[0].Name) + } assert.Equal(t, now, pushCommit.Timestamp) } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 803397f33f..62697d708c 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -29,12 +29,14 @@ import ( type blameRow struct { RowNumber int - Avatar template.HTML PreviousSha string PreviousShaURL string CommitURL string CommitMessage string CommitSince template.HTML + AuthorUser *user_model.User + CoAuthors []*user_model.CoAuthorUser + Author *git.Signature Code template.HTML EscapeStatus *charset.EscapeStatus @@ -221,13 +223,10 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { - if commit.User != nil { - br.Avatar = avatarUtils.Avatar(commit.User, 18) - } else { - br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18) - } - +func renderBlameFillFirstBlameRow(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { + br.AuthorUser = commit.User + br.CoAuthors = commit.CoAuthors + br.Author = commit.Author br.PreviousSha = part.PreviousSha br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) @@ -243,7 +242,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa buf := &bytes.Buffer{} rows := make([]*blameRow, 0) - avatarUtils := templates.NewAvatarUtils(ctx) rowNumber := 0 // will be 1-based for _, part := range blameParts { for partLineIdx, line := range part.Lines { @@ -258,7 +256,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa } if partLineIdx == 0 { - renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br) + renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index fc2eb85b87..96ab2639c5 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -93,9 +93,7 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error // before finally retrieving the latest status func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { var err error - var ok bool - - emails := map[string]*user_model.User{} + emailSet := map[string]struct{}{} keyMap := map[string]bool{} for _, c := range graph.Commits { @@ -106,13 +104,44 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_ if err != nil { return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err) } - if c.Commit.Author != nil { - email := c.Commit.Author.Email - if c.User, ok = emails[email]; !ok { - c.User, _ = user_model.GetUserByEmail(ctx, email) - emails[email] = c.User + emailSet[c.Commit.Author.Email] = struct{}{} + } + for _, sig := range c.Commit.CoAuthorSignatures() { + emailSet[sig.Email] = struct{}{} + } + } + + allEmails := make([]string, 0, len(emailSet)) + for email := range emailSet { + allEmails = append(allEmails, email) + } + var emailUserMap *user_model.EmailUserMap + if len(allEmails) > 0 { + emailUserMap, err = user_model.GetUsersByEmails(ctx, allEmails) + if err != nil { + log.Error("GetUsersByEmails: %v", err) + } + } + + for _, c := range graph.Commits { + if c.Commit == nil { + continue + } + if c.Commit.Author != nil && emailUserMap != nil { + c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) + } + coAuthorSigs := c.Commit.CoAuthorSignatures() + c.CoAuthors = make([]*user_model.CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + var giteaUser *user_model.User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(sig.Email) } + c.CoAuthors = append(c.CoAuthors, &user_model.CoAuthorUser{ + GiteaUser: giteaUser, + TrailerSignature: sig, + }) } c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit) @@ -248,6 +277,7 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { type Commit struct { Commit *git.Commit User *user_model.User + CoAuthors []*user_model.CoAuthorUser Verification *asymkey_model.CommitVerification Status *git_model.CommitStatus Flow int64 diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 8bdefa5d43..e6ae31891d 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{$row.Avatar}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" $row.AuthorUser "AuthorSignature" $row.Author "CoAuthors" $row.CoAuthors}}