mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-17 19:10:22 +03:00
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:
+10
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user