Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ac82797
feat(repo): add co-author parsing and avatar components
bircni May 7, 2026
10461b2
feat(repo): display co-author stacks beside commit SHAs
bircni May 7, 2026
30b1928
more cleanup
bircni May 7, 2026
724d6b7
refactor(repo): move co-author avatar rendering to RenderUtils backen…
bircni May 11, 2026
7a99fc3
Merge branch 'main' into feature/display-co-author
bircni May 11, 2026
48c29df
Merge branch 'main' into feature/display-co-author
bircni May 12, 2026
84d5c58
refactor(repo): polish co-author avatar stack, links, and popup
silverwind May 13, 2026
680a50e
fix(repo): rename AvatarStack and handle nil author in blame rows
silverwind May 13, 2026
72e2810
refactor(repo): drop avatar-stack-wrapper element
silverwind May 13, 2026
1c4d6b4
refactor(repo): simplify co-author render helpers
silverwind May 13, 2026
6707237
refactor: drop Co-committed-by trailer
silverwind May 13, 2026
5d2acd5
Merge branch 'main' into feature/display-co-author
silverwind May 13, 2026
977f311
refactor(repo): polish co-author surfaces per review
silverwind May 13, 2026
9e22d7b
Merge branch 'main' into feature/display-co-author
silverwind May 14, 2026
44ae7cd
Merge remote-tracking branch 'origin/main' into feature/display-co-au…
silverwind May 14, 2026
d6c2e4e
refactor(repo): simplify avatar stack rendering
silverwind May 14, 2026
f2363e6
Merge remote-tracking branch 'bircni/feature/display-co-author' into …
silverwind May 14, 2026
dcbb051
perf(repo): cache no-match result in PushCommit.AuthorUser
silverwind May 14, 2026
241b801
fix(repo): address copilot review on dcbb05147e
silverwind May 14, 2026
1523594
Merge branch 'main' into feature/display-co-author
bircni May 16, 2026
aae73c9
fix(repo): refine avatar stack hover and dark-mode appearance
silverwind May 17, 2026
e7a83f4
fix(repo): fall back to address as name on bare-email co-author trailer
silverwind May 17, 2026
c0d36d2
fix(repo): collapse overflow chip width when avatar stack is unhovered
silverwind May 17, 2026
3779e9e
Merge branch 'main' into feature/display-co-author
silverwind May 17, 2026
9583c8f
refactor(repo): use container.Set for graph commit email gather
silverwind May 17, 2026
d88cd4a
refactor(templates): pass single view struct to CoAuthorAvatars
bircni May 20, 2026
43395fa
Merge branch 'main' into feature/display-co-author
bircni May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions models/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -1147,9 +1147,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
}

Expand All @@ -1165,6 +1172,39 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User {
return u
}

// CoAuthorUsersFromSigs wraps each signature with the matching Gitea user if any.
func CoAuthorUsersFromSigs(sigs []*git.Signature, emailUserMap *EmailUserMap) []*CoAuthorUser {
if len(sigs) == 0 {
return nil
}
out := make([]*CoAuthorUser, len(sigs))
for i, sig := range sigs {
var giteaUser *User
if emailUserMap != nil {
giteaUser = emailUserMap.GetByEmail(sig.Email)
}
out[i] = &CoAuthorUser{GiteaUser: giteaUser, TrailerSignature: sig}
}
return out
}

// 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
}
return CoAuthorUsersFromSigs(sigs, emailUserMap), nil
}

// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users.
func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) {
var (
Expand All @@ -1175,6 +1215,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())
Expand All @@ -1183,10 +1226,10 @@ 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"?
newCommits = append(newCommits, &UserCommit{
User: user,
Commit: c,
User: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"?
CoAuthors: CoAuthorUsersFromSigs(c.CoAuthorSignatures(), emailUserMap),
Commit: c,
})
}
return newCommits, nil
Expand Down
99 changes: 99 additions & 0 deletions modules/git/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,25 @@ import (
"context"
"errors"
"io"
"net/mail"
"os/exec"
"strings"

"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
)

// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
const CoAuthoredByTrailer = "Co-authored-by"

type CommitMessage struct {
MessageRaw string
messageUTF8 *string
messageTitle *string
messageBody *string
coAuthors *[]*Signature
}

// Commit represents a git commit.
Expand Down Expand Up @@ -68,6 +74,99 @@ func (c *CommitMessage) MessageBody() string {
return *c.messageBody
}

// isTrailerLineShape reports whether the line matches `[A-Za-z0-9-]+:<non-empty>`
// per `git interpret-trailers`.
func isTrailerLineShape(line string) bool {
token, rest, ok := strings.Cut(line, ":")
if !ok || token == "" || strings.TrimSpace(rest) == "" {
return false
}
for _, r := range token {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return false
}
return true
}

// parseCoAuthorTrailer extracts a co-author from a `Co-authored-by:` trailer
// (case-insensitive). Returns false on a malformed address.
func parseCoAuthorTrailer(line string) (*Signature, bool) {
token, rest, ok := strings.Cut(line, ":")
if !ok {
return nil, false
}
if !strings.EqualFold(token, CoAuthoredByTrailer) {
return nil, false
}
addr, err := mail.ParseAddress(strings.TrimSpace(rest))
if err != nil {
return nil, false
}
name := addr.Name
if name == "" {
name = addr.Address
}
return &Signature{Name: name, Email: strings.ToLower(addr.Address)}, true
}

// parseCoAuthorSignatures parses `Co-authored-by:` trailers from the trailing
// block of the commit message. Only the last paragraph is scanned (and it must
// contain only trailer-shaped lines) so in-body occurrences inside a revert or
// cherry-pick description are not misinterpreted as trailers.
func (c *CommitMessage) parseCoAuthorSignatures() []*Signature {
if c.coAuthors != nil {
return *c.coAuthors
}
body := strings.TrimRight(util.NormalizeStringEOL(c.MessageBody()), "\n")
if idx := strings.LastIndex(body, "\n\n"); idx >= 0 {
body = body[idx+2:]
}
var sigs []*Signature
seen := container.Set[string]{}
for line := range strings.SplitSeq(body, "\n") {
if !isTrailerLineShape(line) {
sigs = nil
break
}
sig, ok := parseCoAuthorTrailer(line)
if !ok {
continue
}
if !seen.Add(sig.Email) {
continue
}
sigs = append(sigs, sig)
}
c.coAuthors = &sigs
return sigs
}

// CoAuthorSignatures returns the parsed co-author trailers with the commit's own
// author and committer emails filtered out (so self-copying isn't counted).
func (c *Commit) CoAuthorSignatures() []*Signature {
raw := c.parseCoAuthorSignatures()
if len(raw) == 0 {
return raw
}
exclude := container.Set[string]{}
if c.Author != nil {
exclude.Add(strings.ToLower(strings.TrimSpace(c.Author.Email)))
}
if c.Committer != nil {
exclude.Add(strings.ToLower(strings.TrimSpace(c.Committer.Email)))
}
out := make([]*Signature, 0, len(raw))
for _, sig := range raw {
if exclude.Contains(sig.Email) {
continue
}
out = append(out, sig)
}
return out
}

// 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) {
Expand Down
94 changes: 94 additions & 0 deletions modules/git/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,97 @@ func Test_GetCommitBranchStart(t *testing.T) {
assert.NotEmpty(t, startCommitID)
assert.Equal(t, "95bb4d39648ee7e325106df01a621c530863a653", startCommitID)
}

func TestCoAuthorSignatures(t *testing.T) {
cases := []struct {
name string
body string
want []Signature
}{
{
name: "empty",
body: "title",
want: nil,
},
{
name: "single co-author",
body: "title\n\nbody text\n\nCo-authored-by: Jane <jane@example.com>",
want: []Signature{{Name: "Jane", Email: "jane@example.com"}},
},
{
name: "case insensitive token",
body: "title\n\nCo-Authored-By: Jane <Jane@Example.com>\nco-authored-by: Bob <bob@example.com>",
want: []Signature{
{Name: "Jane", Email: "jane@example.com"},
{Name: "Bob", Email: "bob@example.com"},
},
},
{
name: "dedup by lowercased email",
body: "title\n\nCo-authored-by: Jane <jane@example.com>\nCo-authored-by: Janey <JANE@example.com>",
want: []Signature{{Name: "Jane", Email: "jane@example.com"}},
},
{
name: "in-body trailer ignored, only last paragraph counts",
body: "title\n\nCo-authored-by: Mallory <mallory@example.com>\n\nactual body explaining revert\n\nCo-authored-by: Jane <jane@example.com>",
want: []Signature{{Name: "Jane", Email: "jane@example.com"}},
},
{
name: "body text in trailing paragraph rejects co-author line",
body: "title\n\nbody text\nCo-authored-by: Jane <jane@example.com>",
want: nil,
},
{
name: "missing brackets is ignored",
body: "title\n\nCo-authored-by: Jane jane@example.com",
want: nil,
},
{
name: "CRLF line endings",
body: "title\r\n\r\nCo-authored-by: Jane <jane@example.com>\r\nCo-authored-by: Bob <bob@example.com>",
want: []Signature{
{Name: "Jane", Email: "jane@example.com"},
{Name: "Bob", Email: "bob@example.com"},
},
},
{
name: "non-trailer line",
body: "title\n\nSigned-off-by: Jane <jane@example.com>",
want: nil,
},
{
name: "bare-address trailer falls back to address as name",
body: "title\n\nCo-authored-by: <jane@example.com>",
want: []Signature{{Name: "jane@example.com", Email: "jane@example.com"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cm := CommitMessage{MessageRaw: tc.body}
got := cm.parseCoAuthorSignatures()
assert.Len(t, got, len(tc.want))
for i, w := range tc.want {
if i >= len(got) {
break
}
assert.Equal(t, w.Name, got[i].Name, "name[%d]", i)
assert.Equal(t, w.Email, got[i].Email, "email[%d]", i)
}
})
}
}

func TestCommitCoAuthorSignaturesFiltersAuthorAndCommitter(t *testing.T) {
c := &Commit{
Author: &Signature{Name: "Jane", Email: "jane@example.com"},
Committer: &Signature{Name: "Bob", Email: "bob@example.com"},
CommitMessage: CommitMessage{MessageRaw: "title\n\n" +
"Co-authored-by: Jane Self <jane@example.com>\n" +
"Co-authored-by: Bob Self <BOB@example.com>\n" +
"Co-authored-by: Carol <carol@example.com>"},
}
got := c.CoAuthorSignatures()
if assert.Len(t, got, 1) {
assert.Equal(t, "carol@example.com", got[0].Email)
}
}
39 changes: 39 additions & 0 deletions modules/repository/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type PushCommit struct {
AuthorName string
CommitterEmail string
CommitterName string
CoAuthors []*git.Signature `json:",omitempty"`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the PushCommit marshalled as JSON?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Timestamp time.Time
}

Expand Down Expand Up @@ -157,10 +158,48 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit {
AuthorName: commit.Author.Name,
CommitterEmail: commit.Committer.Email,
CommitterName: commit.Committer.Name,
CoAuthors: commit.CoAuthorSignatures(),
Timestamp: commit.Author.When,
}
}

// AuthorSignature returns the push commit author as a git signature.
func (pc *PushCommit) AuthorSignature() *git.Signature {
return &git.Signature{Email: pc.AuthorEmail, Name: pc.AuthorName}
}

// AuthorUser resolves the author email to a Gitea user via per-request cache, nil if no match.
func (pc *PushCommit) AuthorUser(ctx context.Context) *user_model.User {
c := cache.GetContextCache(ctx)
key := "email:" + pc.AuthorEmail
if c != nil {
if v, has := c.Get(cachegroup.User, key); has {
u, _ := v.(*user_model.User)
return u
}
}
u, err := user_model.GetUserByEmail(ctx, pc.AuthorEmail)
if err != nil && !user_model.IsErrUserNotExist(err) {
log.Error("GetUserByEmail: %v", err)
}
if c != nil {
c.Put(cachegroup.User, key, u)
}
return u
}

// CoAuthorUsers returns co-authors in the template view shape, without resolved Gitea users.
func (pc *PushCommit) CoAuthorUsers() []*user_model.CoAuthorUser {
if len(pc.CoAuthors) == 0 {
return nil
}
coAuthors := make([]*user_model.CoAuthorUser, len(pc.CoAuthors))
for i, sig := range pc.CoAuthors {
coAuthors[i] = &user_model.CoAuthorUser{TrailerSignature: sig}
}
return coAuthors
}

// GitToPushCommits transforms a list of git.Commits to PushCommits type.
func GitToPushCommits(gitCommits []*git.Commit) *PushCommits {
commits := make([]*PushCommit, 0, len(gitCommits))
Expand Down
13 changes: 11 additions & 2 deletions modules/repository/commits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,23 @@ func TestCommitToPushCommit(t *testing.T) {
ID: sha1,
Author: sig,
Committer: sig,
CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"},
CommitMessage: git.CommitMessage{MessageRaw: "Commit Message\n\nCo-authored-by: Jane Doe <jane@example.com>"},
})
assert.Equal(t, hexString, pushCommit.Sha1)
assert.Equal(t, "Commit Message", pushCommit.Message)
assert.Equal(t, "Commit Message\n\nCo-authored-by: Jane Doe <jane@example.com>", pushCommit.Message)
assert.Equal(t, "example@example.com", pushCommit.AuthorEmail)
assert.Equal(t, "John Doe", pushCommit.AuthorName)
assert.Equal(t, "example@example.com", pushCommit.CommitterEmail)
assert.Equal(t, "John Doe", pushCommit.CommitterName)
if assert.Len(t, pushCommit.CoAuthors, 1) {
assert.Equal(t, "jane@example.com", pushCommit.CoAuthors[0].Email)
assert.Equal(t, "Jane Doe", pushCommit.CoAuthors[0].Name)
}
assert.Equal(t, &git.Signature{Email: "example@example.com", Name: "John Doe"}, pushCommit.AuthorSignature())
if assert.Len(t, pushCommit.CoAuthorUsers(), 1) {
assert.Equal(t, &git.Signature{Email: "jane@example.com", Name: "Jane Doe"}, pushCommit.CoAuthorUsers()[0].TrailerSignature)
assert.Nil(t, pushCommit.CoAuthorUsers()[0].GiteaUser)
}
assert.Equal(t, now, pushCommit.Timestamp)
}

Expand Down
Loading
Loading