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 -}} + +{{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. +*/}} + 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}} +