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 17:16:22 +00:00
committed by GitHub
co-authored by Claude Opus 4.7 silverwind wxiaoguang Giteabot
parent 2a84831400
commit 54916f708e
44 changed files with 912 additions and 322 deletions
+70 -14
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/gituser"
user_model "gitea.dev/models/user"
"gitea.dev/modules/badge"
"gitea.dev/modules/charset"
@@ -61,8 +62,8 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -73,9 +74,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -86,9 +87,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -99,9 +100,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "other(unmatch)",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -110,9 +111,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
@@ -159,6 +160,59 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
ctx.Data["SelectedStyle"] = selectedStyle
}
func prepareMockDataAvatarStack(ctx *context.Context) {
/*
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 3}})
if len(mockUsers) == 0 {
return
}
u0 := mockUsers[0]
u1, u2 := u0, u0
if len(mockUsers) >= 2 {
u1 = mockUsers[1]
}
if len(mockUsers) >= 3 {
u2 = mockUsers[2]
}
authorSig := func(u *user_model.User) *git.Signature {
return &git.Signature{Name: u.Name, Email: u.Email}
}
coLinked := func(u *user_model.User) *gituser.CommitParticipant {
return &gituser.CommitParticipant{GiteaUser: u, GitIdentity: authorSig(u)}
}
coUnlinked := func(name, email string) *gituser.CommitParticipant {
return &gituser.CommitParticipant{GitIdentity: &git.Signature{Name: name, Email: email}}
}
nUnlinked := func(n int) []*gituser.CommitParticipant {
out := make([]*gituser.CommitParticipant, n)
for i := range out {
out[i] = coUnlinked(fmt.Sprintf("Contributor %d", i+1), fmt.Sprintf("contrib%d@example.com", i+1))
}
return out
}
type scenario struct {
Label string
Data *gituser.AvatarStackData
}
mk := gituser.BuildAvatarStackData()
extSig := &git.Signature{Name: "External Contributor", Email: "external@example.com"}
ctx.Data["AvatarStackScenarios"] = []scenario{
{Label: "linked author, no co-authors", Data: mk(u0, authorSig(u0), nil)},
{Label: "unlinked author, no co-authors", Data: mk(nil, extSig, nil)},
{Label: "1 linked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1)})},
{Label: "1 unlinked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "2 co-authors (3 people), u1 author", Data: mk(u1, authorSig(u1), []*gituser.CommitParticipant{coLinked(u0), coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "3 co-authors mixed (4 people)", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1), coLinked(u2), coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "9 co-authors (max visible, no overflow), u2 author", Data: mk(u2, authorSig(u2), nUnlinked(9))},
{Label: "10 co-authors (overflow +1)", Data: mk(u0, authorSig(u0), nUnlinked(10))},
{Label: "15 co-authors (overflow +6), unlinked author", Data: mk(nil, extSig, nUnlinked(15))},
{Label: "30 co-authors (overflow +21)", Data: mk(u0, authorSig(u0), nUnlinked(30))},
}
*/
}
func prepareMockDataRelativeTime(ctx *context.Context) {
now := time.Now()
ctx.Data["TimeNow"] = now
@@ -196,6 +250,8 @@ func prepareMockData(ctx *context.Context) {
prepareMockDataToastAndMessage(ctx)
case "/devtest/unicode-escape":
prepareMockDataUnicodeEscape(ctx)
case "/devtest/avatar-stack":
prepareMockDataAvatarStack(ctx)
}
}