mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-10 12:23:53 +02:00
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>
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package git
|
|
|
|
import (
|
|
"net/mail"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.dev/modules/charset"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/util"
|
|
)
|
|
|
|
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
|
|
const CoAuthoredByTrailer = "Co-authored-by"
|
|
|
|
type CommitIdentity struct {
|
|
Name string
|
|
Email string
|
|
}
|
|
|
|
// CommitMessageTrailerValues keys are all in lower-case
|
|
type CommitMessageTrailerValues map[string][]string
|
|
|
|
type CommitMessage struct {
|
|
MessageRaw string
|
|
messageUTF8 *string
|
|
messageTitle *string
|
|
messageBody *string
|
|
|
|
trailerValues CommitMessageTrailerValues
|
|
|
|
allParticipants []*CommitIdentity
|
|
}
|
|
|
|
func (c *CommitMessage) MessageUTF8() string {
|
|
if c.messageUTF8 == nil {
|
|
bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}})
|
|
c.messageUTF8 = new(util.UnsafeBytesToString(bs))
|
|
}
|
|
return *c.messageUTF8
|
|
}
|
|
|
|
func (c *CommitMessage) MessageTitle() string {
|
|
if c.messageTitle == nil {
|
|
s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
|
c.messageTitle = new(strings.TrimSpace(s))
|
|
}
|
|
return *c.messageTitle
|
|
}
|
|
|
|
func (c *CommitMessage) MessageBody() string {
|
|
if c.messageBody == nil {
|
|
_, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
|
c.messageBody = new(strings.TrimSpace(s))
|
|
}
|
|
return *c.messageBody
|
|
}
|
|
|
|
func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
|
if c.trailerValues == nil {
|
|
_, _, trailer := CommitMessageSplitTrailer(c.MessageUTF8())
|
|
c.trailerValues = CommitMessageParseTrailer(trailer)
|
|
}
|
|
return c.trailerValues
|
|
}
|
|
|
|
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
|
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
|
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*)$`)
|
|
})
|
|
|
|
func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
|
s = util.NormalizeStringEOL(s)
|
|
re := commitMessageTrailerSplit()
|
|
v := re.FindStringSubmatch(s)
|
|
if v == nil {
|
|
return s, "", ""
|
|
}
|
|
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
|
}
|
|
|
|
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
|
ret := CommitMessageTrailerValues{}
|
|
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
|
k, v, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
|
kLower := strings.ToLower(k)
|
|
ret[kLower] = append(ret[kLower], v)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
|
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
|
if c.allParticipants != nil {
|
|
return c.allParticipants
|
|
}
|
|
|
|
exclude := container.Set[string]{}
|
|
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
|
|
exclude.Add(strings.ToLower(c.Author.Email))
|
|
|
|
addParticipant := func(name, email string) {
|
|
if name == "" && email == "" {
|
|
return
|
|
}
|
|
emailLower := strings.ToLower(email)
|
|
if emailLower != "" && exclude.Contains(emailLower) {
|
|
return
|
|
}
|
|
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
|
|
exclude.Add(emailLower)
|
|
}
|
|
addParticipant(c.Committer.Name, c.Committer.Email)
|
|
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
|
addr, err := mail.ParseAddress(coAuthorValue)
|
|
if err == nil {
|
|
addParticipant(addr.Name, addr.Address)
|
|
} else {
|
|
addParticipant(coAuthorValue, "")
|
|
}
|
|
}
|
|
return c.allParticipants
|
|
}
|