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
+15 -20
View File
@@ -12,8 +12,8 @@ import (
"path"
"strconv"
"gitea.dev/models/gituser"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/charset"
"gitea.dev/modules/git"
"gitea.dev/modules/git/languagestats"
@@ -29,13 +29,14 @@ import (
type blameRow struct {
RowNumber int
Avatar template.HTML
PreviousSha string
PreviousShaURL string
CommitURL string
CommitMessage string
CommitSince template.HTML
AvatarStackData *gituser.AvatarStackData
Code template.HTML
EscapeStatus *charset.EscapeStatus
}
@@ -174,9 +175,9 @@ func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error {
return nil
}
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit {
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*gituser.UserCommit {
// store commit data by SHA to look up avatar info etc
commitNames := make(map[string]*user_model.UserCommit)
commitNames := make(map[string]*gituser.UserCommit)
// and as blameParts can reference the same commits multiple
// times, we cache the lookup work locally
commits := make([]*git.Commit, 0, len(blameParts))
@@ -209,33 +210,28 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma
}
// populate commit email addresses to later look up avatars.
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, ctx.Repo.RepoLink, ctx.Repo.RefFullName)
if err != nil {
ctx.ServerError("ValidateCommitsWithEmails", err)
ctx.ServerError("GetUserCommitsByGitCommits", err)
return nil
}
for _, c := range validatedCommits {
commitNames[c.ID.String()] = c
for _, c := range userCommits {
commitNames[c.GitCommit.ID.String()] = c
}
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(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil)
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))
br.CommitMessage = commit.MessageUTF8()
br.CommitSince = templates.TimeSince(commit.Author.When)
br.CommitMessage = commit.GitCommit.MessageUTF8()
br.CommitSince = templates.TimeSince(commit.GitCommit.Author.When)
}
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) {
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*gituser.UserCommit) {
language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
if err != nil {
log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
@@ -243,7 +239,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 +253,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, ctx.Repo.RepoLink, part, commitNames[part.Sha], br)
}
}
}
+6 -4
View File
@@ -14,6 +14,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/gituser"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
@@ -49,7 +50,7 @@ func RefCommits(ctx *context.Context) {
switch {
case len(ctx.Repo.TreePath) == 0:
Commits(ctx)
case ctx.Repo.TreePath == "search":
case ctx.Repo.TreePath == "search": // FIXME: legacy dirty design, it conflicts with the FileHistory
SearchCommits(ctx)
default:
FileHistory(ctx)
@@ -396,7 +397,8 @@ func Diff(ctx *context.Context) {
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
ctx.Data["Verification"] = verification
ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit)
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:]
ctx.Data["Parents"] = parents
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
@@ -411,7 +413,7 @@ func Diff(ctx *context.Context) {
err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note)
if err == nil {
ctx.Data["NoteCommit"] = note.Commit
ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit)
ctx.Data["NoteAuthor"] = user_model.GetUserByGitAuthor(ctx, note.Commit)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
ctx.Data["NoteRendered"] = markup.PostProcessCommitMessage(rctx, htmlMessage)
@@ -461,7 +463,7 @@ func RawDiff(ctx *context.Context) {
}
func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) {
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository)
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository, ctx.Repo.RefFullName)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -391,14 +391,14 @@ func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*g
if useFirstCommitAsTitle {
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
c := commits[len(commits)-1]
title = c.UserCommit.MessageTitle()
title = c.UserCommit.GitCommit.MessageTitle()
} else {
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
}
if len(commits) == 1 {
c := commits[0]
content = c.MessageBody()
content = c.GitCommit.MessageBody()
}
var titleTrailer string
+3 -3
View File
@@ -9,8 +9,8 @@ import (
asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git"
"gitea.dev/models/gituser"
issues_model "gitea.dev/models/issues"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
git_service "gitea.dev/services/git"
@@ -52,8 +52,8 @@ func TestNewPullRequestTitleContent(t *testing.T) {
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
return &git_model.SignCommitWithStatuses{
SignCommit: &asymkey_model.SignCommit{
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{
UserCommit: &gituser.UserCommit{
GitCommit: &git.Commit{
CommitMessage: git.CommitMessage{MessageRaw: msg},
},
},
+5 -1
View File
@@ -21,6 +21,7 @@ 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"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
@@ -132,8 +133,11 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
ctx.ServerError("CalculateTrustStatus", err)
return false
}
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllParticipantIdentities(), nil)
avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName)
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
ctx.Data["LatestCommitVerification"] = verification
ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit)
statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll)
if err != nil {
+1 -1
View File
@@ -361,7 +361,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
ctx.ServerError("CommitsByFileAndRange", err)
return nil, nil
}
ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository)
ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository, "") // no current ref sub path for wiki commit list
if err != nil {
ctx.ServerError("ConvertFromGitCommit", err)
return nil, nil