feat: Add avatar stacks (#37594)

Parse `Co-authored-by:` trailers from commit messages and surface
contributors as an avatar stack across the commit page, commits list, PR
commits tab, latest-commit row, blame, graph, and dashboard feed.

- Up to 10 visible 20px avatars, GitHub-style overlap (6px first stride,
4px between subsequent), `+N` chip for the rest.
- Label: 1 → name; 2 → `<a> and <b>`; 3+ → `<N> people` opens a Tippy
popup with all participants.
- Names and avatars link to the repo's commits-by-author search; fall
back to profile or `mailto:`.
- Trailer parsing uses `net/mail.ParseAddress`, scans only the trailing
paragraph, filters out the commit's own author/committer.
- Drops the non-standard `Co-committed-by:` emission on squash merge and
web edits.

Devtest: `/devtest/coauthor-avatars`.

Fixes #25521

----
<img width="353" height="277" alt="image"
src="https://github.com/user-attachments/assets/72092ceb-97ca-4b09-9557-0b72d3c5458e"
/>

<img width="533" height="328"
src="https://github.com/user-attachments/assets/11d0c8f8-8b3f-4f2e-9993-879f1c06bcc5"
/>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
bircni
2026-06-08 19:16:22 +02:00
committed by GitHub
parent 2a84831400
commit 54916f708e
44 changed files with 912 additions and 322 deletions
+1 -6
View File
@@ -300,12 +300,7 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit
cmdCommitTree.AddOptionFormat("-S%s", key.KeyID)
if t.repo.GetTrustModel() == repo_model.CommitterTrustModel || t.repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
if committerSig.Name != authorSig.Name || committerSig.Email != authorSig.Email {
// Add trailers
_, _ = messageBytes.WriteString("\n")
_, _ = messageBytes.WriteString("Co-authored-by: ")
_, _ = messageBytes.WriteString(committerSig.String())
_, _ = messageBytes.WriteString("\n")
_, _ = messageBytes.WriteString("Co-committed-by: ")
_, _ = messageBytes.WriteString("\n" + git.CoAuthoredByTrailer + ": ")
_, _ = messageBytes.WriteString(committerSig.String())
_, _ = messageBytes.WriteString("\n")
}
+34 -21
View File
@@ -13,8 +13,10 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/gituser"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
asymkey_service "gitea.dev/services/asymkey"
@@ -93,9 +95,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 := make(container.Set[string])
keyMap := map[string]bool{}
for _, c := range graph.Commits {
@@ -106,14 +106,26 @@ 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.Add(c.Commit.Author.Email)
}
for _, sig := range c.Commit.AllParticipantIdentities() {
emailSet.Add(sig.Email)
}
}
emailUserMap, err := user_model.GetUsersByEmails(ctx, emailSet.Values())
if err != nil {
log.Error("GetUsersByEmails: %v", err)
}
for _, c := range graph.Commits {
if c.Commit == nil {
continue
}
c.User = emailUserMap.GetByEmail(c.Commit.Author.Email)
c.AvatarStackData = gituser.BuildAvatarStackData(ctx, c.Commit.AllParticipantIdentities(), emailUserMap)
c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit)
@@ -246,18 +258,19 @@ func newRefsFromRefNames(refNames []byte) []git.Reference {
// Commit represents a commit at coordinate X, Y with the data
type Commit struct {
Commit *git.Commit
User *user_model.User
Verification *asymkey_model.CommitVerification
Status *git_model.CommitStatus
Flow int64
Row int
Column int
Refs []git.Reference
Rev string
Date time.Time
ShortRev string
Subject string
Commit *git.Commit
User *user_model.User // author
AvatarStackData *gituser.AvatarStackData
Verification *asymkey_model.CommitVerification
Status *git_model.CommitStatus
Flow int64
Row int
Column int
Refs []git.Reference
Rev string
Date time.Time // author date from "%ad"
ShortRev string
Subject string
}
// OnlyRelation returns whether this a relation only commit