mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-10 03:10:00 +00:00
The commit page built its "co-authored by" list from `AllParticipantIdentities()[1:]`, which only drops the author and leaves the committer in the list even though the committer is already shown separately as "committed by". This caused the committer to be wrongly rendered as a co-author (issue #38384): a commit authored by `silverwind`, committed by `bircni`, with a `Co-authored-by: silverwind` trailer displayed "co-authored by bircni" instead of the actual trailer identity. This adds a `Commit.CoAuthorIdentities()` method that excludes both the author and the committer, uses it on the commit page, and covers the fix with a regression test. Closes #38384
148 lines
4.4 KiB
Go
148 lines
4.4 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?)*\n*)$`)
|
|
})
|
|
|
|
// CommitMessageSplitTrailer tries to split the message by the trailer separator
|
|
// content + sep + trailer will reconstruct the original message
|
|
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
|
|
}
|
|
|
|
// CoAuthorIdentities returns the commit's co-authors: the participants without the
|
|
// author and the committer, which are already displayed separately. This avoids
|
|
// surfacing the committer as a "co-author" (see issue #38384).
|
|
func (c *Commit) CoAuthorIdentities() []*CommitIdentity {
|
|
var ret []*CommitIdentity
|
|
for _, p := range c.AllParticipantIdentities()[1:] { // index 0 is always the author
|
|
if c.Committer.Email != "" && strings.EqualFold(p.Email, c.Committer.Email) {
|
|
continue
|
|
}
|
|
ret = append(ret, p)
|
|
}
|
|
return ret
|
|
}
|