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 -9
View File
@@ -9,6 +9,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"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
@@ -17,14 +18,14 @@ import (
)
// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) {
func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*gituser.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) {
newCommits := make([]*asymkey_model.SignCommit, 0, len(oldCommits))
keyMap := map[string]bool{}
emails := make(container.Set[string])
for _, c := range oldCommits {
if c.Committer != nil {
emails.Add(c.Committer.Email)
if c.GitCommit.Committer != nil {
emails.Add(c.GitCommit.Committer.Email)
}
}
@@ -34,10 +35,10 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository,
}
for _, c := range oldCommits {
committerUser := emailUsers.GetByEmail(c.Committer.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"?
committerUser := emailUsers.GetByEmail(c.GitCommit.Committer.Email) // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
signCommit := &asymkey_model.SignCommit{
UserCommit: c,
Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.Commit, committerUser),
Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.GitCommit, committerUser),
}
isOwnerMemberCollaborator := func(user *user_model.User) (bool, error) {
@@ -52,15 +53,15 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository,
}
// ConvertFromGitCommit converts git commits into SignCommitWithStatuses
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository) ([]*git_model.SignCommitWithStatuses, error) {
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository, currentRef git.RefName) ([]*git_model.SignCommitWithStatuses, error) {
userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, repo.Link(), currentRef)
if err != nil {
return nil, err
}
signedCommits, err := ParseCommitsWithSignature(
ctx,
repo,
validatedCommits,
userCommits,
repo.GetTrustModel(),
)
if err != nil {
@@ -77,7 +78,7 @@ func ParseCommitsWithStatus(ctx context.Context, oldCommits []*asymkey_model.Sig
commit := &git_model.SignCommitWithStatuses{
SignCommit: c,
}
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.ID.String(), db.ListOptionsAll)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.GitCommit.ID.String(), db.ListOptionsAll)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -184,7 +184,7 @@ func LoadCommentPushCommits(ctx context.Context, c *issues_model.Comment) error
}
defer closer.Close()
c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo)
c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo, "") // no current ref sub path for PR commit list
if err != nil {
log.Debug("ConvertFromGitCommit: %v", err) // no need to show 500 error to end user when the commit does not exist
} else {
+1 -3
View File
@@ -65,9 +65,7 @@ func doMergeStyleSquash(ctx *mergeContext, message string) error {
}
if setting.Repository.PullRequest.AddCoCommitterTrailers && ctx.committer.String() != sig.String() {
// add trailer
message = AddCommitMessageTailer(message, "Co-authored-by", sig.String())
message = AddCommitMessageTailer(message, "Co-committed-by", sig.String()) // FIXME: this one should be removed, it is not really used or widely used
message = AddCommitMessageTailer(message, git.CoAuthoredByTrailer, sig.String())
}
cmdCommit := gitcmd.NewCommand("commit").
AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email).
+1 -1
View File
@@ -917,7 +917,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
}
for _, author := range authors {
stringBuilder.WriteString("Co-authored-by: ")
stringBuilder.WriteString(git.CoAuthoredByTrailer + ": ")
stringBuilder.WriteString(author)
stringBuilder.WriteRune('\n')
}
+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