mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-11 17:55:32 +02:00
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.
This commit is contained in:
parent
601c6eb1a0
commit
ac82797cd8
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
49
templates/repo/commit_coauthor_avatar_stack.tmpl
Normal file
49
templates/repo/commit_coauthor_avatar_stack.tmpl
Normal file
@ -0,0 +1,49 @@
|
||||
{{if or .CoAuthors .CoAuthorSignatures}}
|
||||
{{- $additionalClasses := .AdditionalClasses -}}
|
||||
<span class="coauthor-avatar-stack {{$additionalClasses}}">
|
||||
{{- if .AuthorUser -}}
|
||||
<a href="{{.AuthorUser.HomeLink}}" data-tooltip-content="{{.AuthorUser.GetDisplayName}}">{{- ctx.AvatarUtils.Avatar .AuthorUser 20 -}}</a>
|
||||
{{- 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 -}}
|
||||
<a href="{{.GiteaUser.HomeLink}}" data-tooltip-content="{{.GiteaUser.GetDisplayName}}">{{- ctx.AvatarUtils.Avatar .GiteaUser 20 -}}</a>
|
||||
{{- else -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20 -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if gt $overflow 0 -}}
|
||||
<span class="coauthor-overflow-chip" data-tooltip-content="{{ctx.Locale.TrN $overflow "repo.commits.coauthor_others_1" "repo.commits.coauthor_others_n" $overflow}}">+{{$overflow}}</span>
|
||||
{{- 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 -}}
|
||||
<span class="coauthor-overflow-chip" data-tooltip-content="{{ctx.Locale.TrN $overflow "repo.commits.coauthor_others_1" "repo.commits.coauthor_others_n" $overflow}}">+{{$overflow}}</span>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{else if .AuthorUser}}
|
||||
{{- ctx.AvatarUtils.Avatar .AuthorUser 20 .AdditionalClasses -}}
|
||||
{{else}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .AuthorSignature.Email .AuthorSignature.Name 20 .AdditionalClasses -}}
|
||||
{{end}}
|
||||
43
templates/repo/commit_coauthor_avatars.tmpl
Normal file
43
templates/repo/commit_coauthor_avatars.tmpl
Normal file
@ -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.
|
||||
*/}}
|
||||
<span class="author-wrapper">
|
||||
{{- 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 -}}
|
||||
<a class="muted" href="{{.GiteaUser.HomeLink}}">{{.GiteaUser.GetDisplayName}}</a>
|
||||
{{- 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 -}}
|
||||
</span>
|
||||
@ -155,6 +155,21 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .CoAuthors}}
|
||||
<div class="flex-text-inline">
|
||||
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.coauthored_by"}}</span>
|
||||
{{range .CoAuthors}}
|
||||
{{if .GiteaUser}}
|
||||
{{ctx.AvatarUtils.Avatar .GiteaUser 20}}
|
||||
<a href="{{.GiteaUser.HomeLink}}"><strong>{{.GiteaUser.GetDisplayName}}</strong></a>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20}}
|
||||
<strong>{{.TrailerSignature.Name}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}}
|
||||
{{end}}
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
<table class="ui very basic table unstackable" id="commits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
|
||||
<th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
|
||||
<th class="one wide"></th>
|
||||
</tr>
|
||||
@ -14,15 +14,7 @@
|
||||
{{range $commit := .Commits}}
|
||||
<tr>
|
||||
<td class="author">
|
||||
<span class="author-wrapper">
|
||||
{{- 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 -}}
|
||||
</span>
|
||||
{{template "repo/commit_coauthor_avatars" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}}
|
||||
</td>
|
||||
<td class="sha">
|
||||
{{$commitBaseLink := ""}}
|
||||
|
||||
@ -2,15 +2,7 @@
|
||||
{{if not .LatestCommit}}
|
||||
…
|
||||
{{else}}
|
||||
<span class="author-wrapper">
|
||||
{{- if .LatestCommitUser -}}
|
||||
{{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommitUser.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{- else if .LatestCommit.Author -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommit.Author.Name}}</strong>
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{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}}
|
||||
|
||||
|
||||
@ -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 <a> (linked user) or <img> (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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user