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
+10 -6
View File
@@ -38,11 +38,15 @@ func TestRepoCommits(t *testing.T) {
doc.doc.Find("#commits-table .commit-id-short").Each(func(i int, s *goquery.Selection) {
commits = append(commits, path.Base(s.AttrOr("href", "")))
})
doc.doc.Find("#commits-table .author-wrapper a").Each(func(i int, s *goquery.Selection) {
doc.doc.Find("#commits-table .avatar-stack-names a.muted").Each(func(i int, s *goquery.Selection) {
userHrefs = append(userHrefs, s.AttrOr("href", ""))
})
assert.Equal(t, []string{"69554a64c1e6030f051e5c3f94bfbd773cd6a324", "27566bd5738fc8b4e3fef3c5e72cce608537bd95", "5099b81332712fe655e34e8dd63574f503f61811"}, commits)
assert.Equal(t, []string{"/user2", "/user21", "/user2"}, userHrefs)
assert.Equal(t, []string{
"/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com",
"/user2/repo16/commits/branch/master/search?q=author%3Auser21%40example.com",
"/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com",
}, userHrefs)
})
t.Run("LastCommit", func(t *testing.T) {
@@ -50,9 +54,9 @@ func TestRepoCommits(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "")
authorHref := doc.doc.Find(".latest-commit .author-wrapper a").AttrOr("href", "")
authorHref := doc.doc.Find(".latest-commit .avatar-stack-names a").AttrOr("href", "")
assert.Equal(t, "/user2/repo16/commit/69554a64c1e6030f051e5c3f94bfbd773cd6a324", commitHref)
assert.Equal(t, "/user2", authorHref)
assert.Equal(t, "/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com", authorHref)
})
t.Run("CommitListNonExistingCommiter", func(t *testing.T) {
@@ -65,7 +69,7 @@ func TestRepoCommits(t *testing.T) {
doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find("#commits-table tr:first-child .commit-id-short").AttrOr("href", "")
assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref)
authorElem := doc.doc.Find("#commits-table tr:first-child .author-wrapper")
authorElem := doc.doc.Find("#commits-table tr:first-child .avatar-stack-names")
assert.Equal(t, "6543", strings.TrimSpace(authorElem.Text()))
})
@@ -97,7 +101,7 @@ func TestRepoCommits(t *testing.T) {
doc := NewHTMLParser(t, resp.Body)
commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "")
assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref)
authorElem := doc.doc.Find(".latest-commit .author-wrapper")
authorElem := doc.doc.Find(".latest-commit .avatar-stack-names")
assert.Equal(t, "6543", strings.TrimSpace(authorElem.Text()))
})
}