From ac82797cd8630275d61bab689f077187b9326277 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 7 May 2026 21:52:54 +0200 Subject: [PATCH 1/3] feat(repo): add co-author parsing and avatar components Extract and harden co-author trailer parsing, add reusable co-author avatar templates, and wire commit/repo views to render co-author metadata. --- models/user/user.go | 46 +++++++++++++- modules/git/commit.go | 53 ++++++++++++++-- options/locale/locale_en-US.json | 4 ++ routers/web/repo/commit.go | 5 ++ routers/web/repo/view.go | 6 ++ .../repo/commit_coauthor_avatar_stack.tmpl | 49 +++++++++++++++ templates/repo/commit_coauthor_avatars.tmpl | 43 +++++++++++++ templates/repo/commit_page.tmpl | 15 +++++ templates/repo/commits_list.tmpl | 14 +---- templates/repo/latest_commit.tmpl | 10 +-- web_src/css/repo.css | 63 ++++++++++++++++++- 11 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 templates/repo/commit_coauthor_avatar_stack.tmpl create mode 100644 templates/repo/commit_coauthor_avatars.tmpl diff --git a/models/user/user.go b/models/user/user.go index 8a39eca634..7a45d70878 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1140,9 +1140,16 @@ func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) { return users, err } +// CoAuthorUser represents a co-author parsed from a commit trailer, with optional Gitea user. +type CoAuthorUser struct { + GiteaUser *User + TrailerSignature *git.Signature +} + // UserCommit represents a commit with validation of user. type UserCommit struct { //revive:disable-line:exported - User *User + User *User + CoAuthors []*CoAuthorUser *git.Commit } @@ -1158,6 +1165,27 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { return u } +// CoAuthorsFromCommit resolves co-author signatures from a commit into CoAuthorUser values. +func CoAuthorsFromCommit(ctx context.Context, c *git.Commit) ([]*CoAuthorUser, error) { + sigs := c.CoAuthorSignatures() + if len(sigs) == 0 { + return nil, nil + } + emails := make([]string, len(sigs)) + for i, sig := range sigs { + emails[i] = sig.Email + } + emailUserMap, err := GetUsersByEmails(ctx, emails) + if err != nil { + return nil, err + } + coAuthors := make([]*CoAuthorUser, len(sigs)) + for i, sig := range sigs { + coAuthors[i] = &CoAuthorUser{GiteaUser: emailUserMap.GetByEmail(sig.Email), TrailerSignature: sig} + } + return coAuthors, nil +} + // ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) { var ( @@ -1168,6 +1196,9 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ if c.Author != nil { emailSet.Add(c.Author.Email) } + for _, sig := range c.CoAuthorSignatures() { + emailSet.Add(sig.Email) + } } emailUserMap, err := GetUsersByEmails(ctx, emailSet.Values()) @@ -1177,9 +1208,18 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ for _, c := range oldCommits { user := emailUserMap.GetByEmail(c.Author.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? + coAuthorSigs := c.CoAuthorSignatures() + coAuthors := make([]*CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + coAuthors = append(coAuthors, &CoAuthorUser{ + GiteaUser: emailUserMap.GetByEmail(sig.Email), + TrailerSignature: sig, + }) + } newCommits = append(newCommits, &UserCommit{ - User: user, - Commit: c, + User: user, + CoAuthors: coAuthors, + Commit: c, }) } return newCommits, nil diff --git a/modules/git/commit.go b/modules/git/commit.go index b576451db8..34e6d64b86 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -17,10 +17,11 @@ import ( ) type CommitMessage struct { - MessageRaw string - messageUTF8 *string - messageTitle *string - messageBody *string + MessageRaw string + messageUTF8 *string + messageTitle *string + messageBody *string + messageCoAuthors *[]*Signature } // Commit represents a git commit. @@ -68,6 +69,50 @@ func (c *CommitMessage) MessageBody() string { return *c.messageBody } +// CoAuthorSignatures parses "Co-authored-by:" and "Co-committed-by:" trailers +// from the trailing block of the commit message and returns deduplicated +// Signature values. Only the last paragraph of the body is scanned so that +// quoted or in-body occurrences (e.g. inside a revert/cherry-pick description) +// are not misinterpreted as trailers, matching `git interpret-trailers`. +func (c *CommitMessage) CoAuthorSignatures() []*Signature { + if c.messageCoAuthors != nil { + return *c.messageCoAuthors + } + var sigs []*Signature + seen := make(map[string]struct{}) + body := strings.TrimRight(c.MessageBody(), "\n") + if idx := strings.LastIndex(body, "\n\n"); idx >= 0 { + body = body[idx+2:] + } + for line := range strings.SplitSeq(body, "\n") { + var rest string + var ok bool + if rest, ok = strings.CutPrefix(line, "Co-authored-by:"); !ok { + rest, ok = strings.CutPrefix(line, "Co-committed-by:") + if !ok { + continue + } + } + rest = strings.TrimSpace(rest) + name, emailWithBracket, ok := strings.Cut(rest, " <") + if !ok { + continue + } + email, _, ok := strings.Cut(emailWithBracket, ">") + if !ok { + continue + } + email = strings.ToLower(strings.TrimSpace(email)) + if _, exists := seen[email]; exists { + continue + } + seen[email] = struct{}{} + sigs = append(sigs, &Signature{Name: strings.TrimSpace(name), Email: email}) + } + c.messageCoAuthors = &sigs + return sigs +} + // ParentID returns oid of n-th parent (0-based index). // It returns nil if no such parent exists. func (c *Commit) ParentID(n int) (ObjectID, error) { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..18acdb059c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2589,6 +2589,10 @@ "repo.diff.review.reject": "Request changes", "repo.diff.review.self_approve": "Pull request authors can't approve their own pull request", "repo.diff.committed_by": "committed by", + "repo.diff.coauthored_by": "co-authored by", + "repo.commits.coauthor_and": "and", + "repo.commits.coauthor_others_1": "%d other", + "repo.commits.coauthor_others_n": "%d others", "repo.diff.protected": "Protected", "repo.diff.image.side_by_side": "Side by Side", "repo.diff.image.swipe": "Swipe", diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 6c973696ff..b54a1dbf9b 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -382,6 +382,11 @@ func Diff(ctx *context.Context) { verification := asymkey_service.ParseCommitWithSignature(ctx, commit) ctx.Data["Verification"] = verification ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, commit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["CoAuthors"] = coAuthors + } ctx.Data["Parents"] = parents ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2d95d5233e..d0cf7d6f6f 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -135,6 +135,12 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { ctx.Data["LatestCommitVerification"] = verification ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, latestCommit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["LatestCommitCoAuthors"] = coAuthors + } + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) diff --git a/templates/repo/commit_coauthor_avatar_stack.tmpl b/templates/repo/commit_coauthor_avatar_stack.tmpl new file mode 100644 index 0000000000..772e054aaa --- /dev/null +++ b/templates/repo/commit_coauthor_avatar_stack.tmpl @@ -0,0 +1,49 @@ +{{if or .CoAuthors .CoAuthorSignatures}} + {{- $additionalClasses := .AdditionalClasses -}} + + {{- if .AuthorUser -}} + {{- ctx.AvatarUtils.Avatar .AuthorUser 20 -}} + {{- else -}} + {{- ctx.AvatarUtils.AvatarByEmail .AuthorSignature.Email .AuthorSignature.Name 20 -}} + {{- end -}} + {{- if .CoAuthors -}} + {{- $coCount := len .CoAuthors -}} + {{- $maxCo := 9 -}} + {{- $visibleCo := .CoAuthors -}} + {{- $overflow := 0 -}} + {{- if gt $coCount $maxCo -}} + {{- $visibleCo = slice .CoAuthors 0 $maxCo -}} + {{- $overflow = Eval $coCount "-" $maxCo -}} + {{- end -}} + {{- range $visibleCo -}} + {{- if .GiteaUser -}} + {{- ctx.AvatarUtils.Avatar .GiteaUser 20 -}} + {{- else -}} + {{- ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20 -}} + {{- end -}} + {{- end -}} + {{- if gt $overflow 0 -}} + +{{$overflow}} + {{- end -}} + {{- else -}} + {{- $coCount := len .CoAuthorSignatures -}} + {{- $maxCo := 9 -}} + {{- $visibleCo := .CoAuthorSignatures -}} + {{- $overflow := 0 -}} + {{- if gt $coCount $maxCo -}} + {{- $visibleCo = slice .CoAuthorSignatures 0 $maxCo -}} + {{- $overflow = Eval $coCount "-" $maxCo -}} + {{- end -}} + {{- range $visibleCo -}} + {{- ctx.AvatarUtils.AvatarByEmail .Email .Name 20 -}} + {{- end -}} + {{- if gt $overflow 0 -}} + +{{$overflow}} + {{- end -}} + {{- end -}} + +{{else if .AuthorUser}} + {{- ctx.AvatarUtils.Avatar .AuthorUser 20 .AdditionalClasses -}} +{{else}} + {{- ctx.AvatarUtils.AvatarByEmail .AuthorSignature.Email .AuthorSignature.Name 20 .AdditionalClasses -}} +{{end}} diff --git a/templates/repo/commit_coauthor_avatars.tmpl b/templates/repo/commit_coauthor_avatars.tmpl new file mode 100644 index 0000000000..04c04d5186 --- /dev/null +++ b/templates/repo/commit_coauthor_avatars.tmpl @@ -0,0 +1,43 @@ +{{/* + Renders the author/co-author avatar stack and name text for a commit. + Args (via dict): + AuthorUser - *user_model.User (may be nil if no Gitea account) + AuthorSignature - *git.Signature (always set: name + email from git) + CoAuthors - []*user_model.CoAuthorUser + fields: GiteaUser (*User, may be nil), TrailerSignature (*git.Signature) + + Avatar stack is capped at 10 children (author + up to 9 co-authors); any + remainder is collapsed into a trailing "+N" chip. The .coauthor-avatar-stack + CSS rules expect at most 10 children to fan out cleanly on hover. +*/}} + +{{- if .CoAuthors -}} + {{- $coCount := len .CoAuthors -}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .AuthorUser "AuthorSignature" .AuthorSignature "CoAuthors" .CoAuthors}} + {{- if .AuthorUser -}} + {{- .AuthorUser.GetShortDisplayNameLinkHTML -}} + {{- else -}} + {{.AuthorSignature.Name}} + {{- end -}} + {{" "}}{{ctx.Locale.Tr "repo.commits.coauthor_and"}}{{" "}} + {{- if eq $coCount 1 -}} + {{- with index .CoAuthors 0 -}} + {{- if .GiteaUser -}} + {{.GiteaUser.GetDisplayName}} + {{- else -}} + {{.TrailerSignature.Name}} + {{- end -}} + {{- end -}} + {{- else -}} + {{- ctx.Locale.TrN $coCount "repo.commits.coauthor_others_1" "repo.commits.coauthor_others_n" $coCount -}} + {{- end -}} +{{- else -}} + {{- if .AuthorUser -}} + {{- template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .AuthorUser "AuthorSignature" .AuthorSignature "AdditionalClasses" "tw-mr-1" -}} + {{- .AuthorUser.GetShortDisplayNameLinkHTML -}} + {{- else -}} + {{- template "repo/commit_coauthor_avatar_stack" dict "AuthorSignature" .AuthorSignature "AdditionalClasses" "tw-mr-1" -}} + {{.AuthorSignature.Name}} + {{- end -}} +{{- end -}} + diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 975cd303ec..cda0d29723 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -155,6 +155,21 @@ {{end}} + {{if .CoAuthors}} +
+ {{ctx.Locale.Tr "repo.diff.coauthored_by"}} + {{range .CoAuthors}} + {{if .GiteaUser}} + {{ctx.AvatarUtils.Avatar .GiteaUser 20}} + {{.GiteaUser.GetDisplayName}} + {{else}} + {{ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20}} + {{.TrailerSignature.Name}} + {{end}} + {{end}} +
+ {{end}} + {{if .Verification}} {{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}} {{end}} diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index e79d189b8d..e80b6cdc24 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -2,9 +2,9 @@ - + - + @@ -14,15 +14,7 @@ {{range $commit := .Commits}}
{{ctx.Locale.Tr "repo.commits.author"}}{{ctx.Locale.Tr "repo.commits.author"}} {{StringUtils.ToUpper $.Repository.ObjectFormatName}}{{ctx.Locale.Tr "repo.commits.message"}}{{ctx.Locale.Tr "repo.commits.message"}} {{ctx.Locale.Tr "repo.commits.date"}}
- - {{- if .User -}} - {{- ctx.AvatarUtils.Avatar .User 20 "tw-mr-2" -}} - {{- .User.GetShortDisplayNameLinkHTML -}} - {{- else -}} - {{- ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20 "tw-mr-2" -}} - {{- .Author.Name -}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} {{$commitBaseLink := ""}} diff --git a/templates/repo/latest_commit.tmpl b/templates/repo/latest_commit.tmpl index c0518189b8..3a3b162ba5 100644 --- a/templates/repo/latest_commit.tmpl +++ b/templates/repo/latest_commit.tmpl @@ -2,15 +2,7 @@ {{if not .LatestCommit}} … {{else}} - - {{- if .LatestCommitUser -}} - {{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}} - {{.LatestCommitUser.GetShortDisplayNameLinkHTML}} - {{- else if .LatestCommit.Author -}} - {{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}} - {{.LatestCommit.Author.Name}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .LatestCommitUser "AuthorSignature" .LatestCommit.Author "CoAuthors" .LatestCommitCoAuthors}} {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 3eb016650f..135eeec281 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1403,11 +1403,72 @@ tbody.commit-list { } .author-wrapper { - max-width: 180px; + max-width: 240px; align-self: center; white-space: nowrap; } +.coauthor-avatar-stack { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-right: 0.25rem; +} + +/* each direct child of the stack is either (linked user) or (no account) */ +.coauthor-avatar-stack > * { + margin-left: -12px; + transition: transform 0.15s ease; + position: relative; + z-index: 0; + display: inline-flex; + border-radius: 50%; +} + +.coauthor-avatar-stack > *:first-child { + margin-left: 0; +} + +.coauthor-avatar-stack .avatar { + border: 2px solid var(--color-body); + border-radius: 50%; +} + +.coauthor-overflow-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 10px; + font-weight: var(--font-weight-semibold); + border: 2px solid var(--color-body); + border-radius: 50%; + background: var(--color-secondary); + color: var(--color-text); +} + +.coauthor-avatar-stack > *:nth-child(1) { z-index: 20; } +.coauthor-avatar-stack > *:nth-child(2) { z-index: 19; } +.coauthor-avatar-stack > *:nth-child(3) { z-index: 18; } +.coauthor-avatar-stack > *:nth-child(4) { z-index: 17; } +.coauthor-avatar-stack > *:nth-child(5) { z-index: 16; } +.coauthor-avatar-stack > *:nth-child(6) { z-index: 15; } +.coauthor-avatar-stack > *:nth-child(7) { z-index: 14; } +.coauthor-avatar-stack > *:nth-child(8) { z-index: 13; } +.coauthor-avatar-stack > *:nth-child(9) { z-index: 12; } +.coauthor-avatar-stack > *:nth-child(10) { z-index: 11; } + +.coauthor-avatar-stack:hover > *:nth-child(2) { transform: translateX(12px); } +.coauthor-avatar-stack:hover > *:nth-child(3) { transform: translateX(24px); } +.coauthor-avatar-stack:hover > *:nth-child(4) { transform: translateX(36px); } +.coauthor-avatar-stack:hover > *:nth-child(5) { transform: translateX(48px); } +.coauthor-avatar-stack:hover > *:nth-child(6) { transform: translateX(60px); } +.coauthor-avatar-stack:hover > *:nth-child(7) { transform: translateX(72px); } +.coauthor-avatar-stack:hover > *:nth-child(8) { transform: translateX(84px); } +.coauthor-avatar-stack:hover > *:nth-child(9) { transform: translateX(96px); } +.coauthor-avatar-stack:hover > *:nth-child(10) { transform: translateX(108px); } + .latest-commit .message-wrapper { max-width: calc(100% - 2.5rem); } From 10461b2f4e4539f3088a3c90a091457b2f117abf Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 7 May 2026 21:53:01 +0200 Subject: [PATCH 2/3] feat(repo): display co-author stacks beside commit SHAs Show co-author avatar stacks on commit SHA surfaces including commits list rows, graph, blame, and dashboard feeds, with repository-level data propagation and tests. Co-Authored-By: Claude Opus 4.7 --- modules/repository/commits.go | 2 + modules/repository/commits_test.go | 8 +++- routers/web/repo/blame.go | 18 ++++---- services/repository/gitgraph/graph_models.go | 46 ++++++++++++++++---- templates/repo/blame.tmpl | 2 +- templates/repo/commit_sign_badge.tmpl | 4 +- templates/repo/commits_list_small.tmpl | 6 +-- templates/repo/graph/commits.tmpl | 9 +--- templates/user/dashboard/feeds.tmpl | 2 +- 9 files changed, 60 insertions(+), 37 deletions(-) diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 32550a9f03..546e881090 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -29,6 +29,7 @@ type PushCommit struct { AuthorName string CommitterEmail string CommitterName string + CoAuthors []*git.Signature Timestamp time.Time } @@ -157,6 +158,7 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit { AuthorName: commit.Author.Name, CommitterEmail: commit.Committer.Email, CommitterName: commit.Committer.Name, + CoAuthors: commit.CoAuthorSignatures(), Timestamp: commit.Author.When, } } diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 46db7b028b..a4c0eea8b5 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -145,14 +145,18 @@ func TestCommitToPushCommit(t *testing.T) { ID: sha1, Author: sig, Committer: sig, - CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"}, + CommitMessage: git.CommitMessage{MessageRaw: "Commit Message\n\nCo-authored-by: Jane Doe "}, }) assert.Equal(t, hexString, pushCommit.Sha1) - assert.Equal(t, "Commit Message", pushCommit.Message) + assert.Equal(t, "Commit Message\n\nCo-authored-by: Jane Doe ", pushCommit.Message) assert.Equal(t, "example@example.com", pushCommit.AuthorEmail) assert.Equal(t, "John Doe", pushCommit.AuthorName) assert.Equal(t, "example@example.com", pushCommit.CommitterEmail) assert.Equal(t, "John Doe", pushCommit.CommitterName) + if assert.Len(t, pushCommit.CoAuthors, 1) { + assert.Equal(t, "jane@example.com", pushCommit.CoAuthors[0].Email) + assert.Equal(t, "Jane Doe", pushCommit.CoAuthors[0].Name) + } assert.Equal(t, now, pushCommit.Timestamp) } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 803397f33f..62697d708c 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -29,12 +29,14 @@ import ( type blameRow struct { RowNumber int - Avatar template.HTML PreviousSha string PreviousShaURL string CommitURL string CommitMessage string CommitSince template.HTML + AuthorUser *user_model.User + CoAuthors []*user_model.CoAuthorUser + Author *git.Signature Code template.HTML EscapeStatus *charset.EscapeStatus @@ -221,13 +223,10 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma 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(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { + br.AuthorUser = commit.User + br.CoAuthors = commit.CoAuthors + br.Author = commit.Author 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)) @@ -243,7 +242,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 +256,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.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index fc2eb85b87..96ab2639c5 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -93,9 +93,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 := map[string]struct{}{} keyMap := map[string]bool{} for _, c := range graph.Commits { @@ -106,13 +104,44 @@ 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[c.Commit.Author.Email] = struct{}{} + } + for _, sig := range c.Commit.CoAuthorSignatures() { + emailSet[sig.Email] = struct{}{} + } + } + + allEmails := make([]string, 0, len(emailSet)) + for email := range emailSet { + allEmails = append(allEmails, email) + } + var emailUserMap *user_model.EmailUserMap + if len(allEmails) > 0 { + emailUserMap, err = user_model.GetUsersByEmails(ctx, allEmails) + if err != nil { + log.Error("GetUsersByEmails: %v", err) + } + } + + for _, c := range graph.Commits { + if c.Commit == nil { + continue + } + if c.Commit.Author != nil && emailUserMap != nil { + c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) + } + coAuthorSigs := c.Commit.CoAuthorSignatures() + c.CoAuthors = make([]*user_model.CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + var giteaUser *user_model.User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(sig.Email) } + c.CoAuthors = append(c.CoAuthors, &user_model.CoAuthorUser{ + GiteaUser: giteaUser, + TrailerSignature: sig, + }) } c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit) @@ -248,6 +277,7 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { type Commit struct { Commit *git.Commit User *user_model.User + CoAuthors []*user_model.CoAuthorUser Verification *asymkey_model.CommitVerification Status *git_model.CommitStatus Flow int64 diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 8bdefa5d43..e6ae31891d 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{$row.Avatar}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" $row.AuthorUser "AuthorSignature" $row.Author "CoAuthors" $row.CoAuthors}}