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); }