diff --git a/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go index 17244076dd..251d8eff11 100644 --- a/models/asymkey/gpg_key_commit_verification.go +++ b/models/asymkey/gpg_key_commit_verification.go @@ -8,6 +8,7 @@ import ( "fmt" "hash" + "gitea.dev/models/gituser" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/log" @@ -32,8 +33,8 @@ type CommitVerification struct { // SignCommit represents a commit with validation of signature. type SignCommit struct { - Verification *CommitVerification - *user_model.UserCommit + Verification *CommitVerification + *gituser.UserCommit // TODO: need to use a explicit field name, avoid anonymous field } const ( diff --git a/models/gituser/avatar_stack.go b/models/gituser/avatar_stack.go new file mode 100644 index 0000000000..d38c94bc7b --- /dev/null +++ b/models/gituser/avatar_stack.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gituser + +import ( + "context" + + "gitea.dev/models/user" + "gitea.dev/modules/git" + "gitea.dev/modules/log" +) + +// AvatarStackData is the view-model for the AvatarStack render helpers. Participants[0] is +// the primary participant (commit author), painted on top; the rest follow. +type AvatarStackData struct { + Participants []*CommitParticipant + SearchByEmailLink string +} + +func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIdentity, emailUserMap *user.EmailUserMap) *AvatarStackData { + if emailUserMap == nil { + emails := make([]string, len(allParticipants)) + for i, sig := range allParticipants { + emails[i] = sig.Email + } + var err error + emailUserMap, err = user.GetUsersByEmails(ctx, emails) + if err != nil { + log.Error("GetUsersByEmails failed: %v", err) + } + } + ret := &AvatarStackData{ + Participants: make([]*CommitParticipant, 0, len(allParticipants)), + } + for _, p := range allParticipants { + var giteaUser *user.User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(p.Email) + } + ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p}) + } + return ret +} diff --git a/models/gituser/gituser.go b/models/gituser/gituser.go new file mode 100644 index 0000000000..81a94a3a85 --- /dev/null +++ b/models/gituser/gituser.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gituser + +import ( + "context" + "net/url" + + "gitea.dev/models/user" + "gitea.dev/modules/container" + "gitea.dev/modules/git" +) + +// CommitParticipant is one participant of a commit (its author or a co-author): +// a git identity, optionally matched to a Gitea user. +type CommitParticipant struct { + GitIdentity *git.CommitIdentity // git identity (name/email), never nil + GiteaUser *user.User // matched Gitea user, nil if unmatched +} + +// UserCommit represents a commit with matched of database "author" user. +type UserCommit struct { + GitCommit *git.Commit + AuthorUser *user.User + AvatarStackData *AvatarStackData +} + +func RepoCommitSearchByEmailLink(repoLink string, ref git.RefName) string { + if curRefWebLinkPath := ref.RefWebLinkPath(); curRefWebLinkPath != "" { + return repoLink + "/commits/" + curRefWebLinkPath + "/search?q=" + url.QueryEscape("author:") + "{email}" + } + return "" +} + +// GetUserCommitsByGitCommits checks if authors' e-mails of commits are corresponding to users. +func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, repoLink string, currentRef git.RefName) ([]*UserCommit, error) { + userCommits := make([]*UserCommit, 0, len(gitCommits)) + emailSet := make(container.Set[string]) + for _, c := range gitCommits { + emailSet.Add(c.Author.Email) + emailSet.Add(c.Committer.Email) + for _, p := range c.AllParticipantIdentities() { + emailSet.Add(p.Email) + } + } + + emailUserMap, err := user.GetUsersByEmails(ctx, emailSet.Values()) + if err != nil { + return nil, err + } + + searchByEmailLink := RepoCommitSearchByEmailLink(repoLink, currentRef) + for _, c := range gitCommits { + uc := &UserCommit{ + AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"? + GitCommit: c, + AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap), + } + uc.AvatarStackData.SearchByEmailLink = searchByEmailLink + userCommits = append(userCommits, uc) + } + return userCommits, nil +} diff --git a/models/user/user.go b/models/user/user.go index 66e8d49b42..4e6227de9e 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1148,14 +1148,7 @@ func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) { return users, err } -// UserCommit represents a commit with validation of user. -type UserCommit struct { //revive:disable-line:exported - User *User - *git.Commit -} - -// ValidateCommitWithEmail check if author's e-mail of commit is corresponding to a user. -func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { +func GetUserByGitAuthor(ctx context.Context, c *git.Commit) *User { if c.Author == nil { return nil } @@ -1166,33 +1159,6 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { return u } -// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. -func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) { - var ( - newCommits = make([]*UserCommit, 0, len(oldCommits)) - emailSet = make(container.Set[string]) - ) - for _, c := range oldCommits { - if c.Author != nil { - emailSet.Add(c.Author.Email) - } - } - - emailUserMap, err := GetUsersByEmails(ctx, emailSet.Values()) - if err != nil { - return nil, err - } - - 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, - }) - } - return newCommits, nil -} - type EmailUserMap struct { m map[string]*User } @@ -1203,7 +1169,7 @@ func (eum *EmailUserMap) GetByEmail(email string) *User { func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, error) { if len(emails) == 0 { - return nil, nil //nolint:nilnil // return nil when there are no emails to look up + return &EmailUserMap{}, nil } needCheckEmails := make(container.Set[string]) diff --git a/modules/git/commit.go b/modules/git/commit.go index 0231770a29..21288ad845 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -11,18 +11,10 @@ import ( "os/exec" "strings" - "gitea.dev/modules/charset" "gitea.dev/modules/git/gitcmd" "gitea.dev/modules/util" ) -type CommitMessage struct { - MessageRaw string - messageUTF8 *string - messageTitle *string - messageBody *string -} - // Commit represents a git commit. type Commit struct { Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache" @@ -44,30 +36,6 @@ type CommitSignature struct { Payload string } -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 -} - // 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/modules/git/commit_message.go b/modules/git/commit_message.go new file mode 100644 index 0000000000..8fd3601f0d --- /dev/null +++ b/modules/git/commit_message.go @@ -0,0 +1,131 @@ +// 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.*?)(?P^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P(?:[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 +} diff --git a/modules/git/commit_message_test.go b/modules/git/commit_message_test.go new file mode 100644 index 0000000000..049f1c03f7 --- /dev/null +++ b/modules/git/commit_message_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) { + commit := &Commit{ + CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"}, + } + assert.Equal(t, "title ÿ", commit.MessageTitle()) + assert.Equal(t, "body ÿ", commit.MessageBody()) + assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8()) +} + +func TestCommitMessageTrailer(t *testing.T) { + cases := []struct { + msg, body, sep, trailer string + }{ + {"", "", "", ""}, + {"a", "a", "", ""}, + {"a\n\nk", "a\n\nk", "", ""}, + {"a\n\nk:v", "a", "\n\n", "k:v"}, + {"a\n--\nk:v", "a\n--\nk:v", "", ""}, + {"a\n---\nk:v", "a", "\n---\n", "k:v"}, + + {"k: v", "", "", "k: v"}, + {"\nk:v", "", "\n", "k:v"}, + {"\n\nk:v", "", "\n\n", "k:v"}, + + {"---\nk:v", "", "---\n", "k:v"}, + {"\n---\nk:v", "", "\n---\n", "k:v"}, + {"a:b\n---\nk:v", "a:b", "\n---\n", "k:v"}, + } + for _, c := range cases { + body, sep, trailer := CommitMessageSplitTrailer(c.msg) + assert.Equal(t, c.body, body, "input=%q", c.msg) + assert.Equal(t, c.sep, sep, "input=%q", c.msg) + assert.Equal(t, c.trailer, trailer, "input=%q", c.msg) + } +} + +func TestCommitMessageAllParticipantIdentities(t *testing.T) { + sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} } + idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} } + cases := []struct { + commit *Commit + participant []*CommitIdentity + }{ + { + &Commit{ + Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"), + CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"}, + }, + []*CommitIdentity{idt("a", "a@m.com"), idt("c", "c@m.com"), idt("", "x@m.com")}, + }, + { + &Commit{ + Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"), + CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"}, + }, + []*CommitIdentity{idt("a", "a@m.com")}, + }, + { + &Commit{ + Author: sig("a", "a@m.com"), Committer: sig("", ""), + CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name "}, + }, + []*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")}, + }, + } + for _, c := range cases { + assert.Equal(t, c.participant, c.commit.AllParticipantIdentities()) + } +} diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index a7668e4deb..5e3d2fba71 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -159,15 +159,6 @@ ISO-8859-1`, commitFromReader.Signature.Payload) assert.Equal(t, commitFromReader, commitFromReader2) } -func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) { - commit := &Commit{ - CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"}, - } - assert.Equal(t, "title ÿ", commit.MessageTitle()) - assert.Equal(t, "body ÿ", commit.MessageBody()) - assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8()) -} - func TestHasPreviousCommit(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") diff --git a/modules/repository/commits.go b/modules/repository/commits.go index d40af3d82c..318b052ef1 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -9,19 +9,15 @@ import ( "net/url" "time" - "gitea.dev/models/avatars" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" - "gitea.dev/modules/cache" - "gitea.dev/modules/cachegroup" "gitea.dev/modules/git" "gitea.dev/modules/gitrepo" - "gitea.dev/modules/log" - "gitea.dev/modules/setting" api "gitea.dev/modules/structs" ) // PushCommit represents a commit in a push operation. +// This struct is marshaled as JSON (see ActionContent2Commits) type PushCommit struct { Sha1 string Message string @@ -33,6 +29,7 @@ type PushCommit struct { } // PushCommits represents list of commits in a push operation. +// This struct is marshaled as JSON (see ActionContent2Commits) type PushCommits struct { Commits []*PushCommit HeadCommit *PushCommit @@ -128,26 +125,6 @@ func (pc *PushCommits) ToAPIPayloadCommits(ctx context.Context, repo *repo_model return commits, headCommit, nil } -// AvatarLink tries to match user in database with e-mail -// in order to show custom avatar, and falls back to general avatar link. -func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string { - size := avatars.DefaultAvatarPixelSize * setting.Avatar.RenderedSizeFactor - - v, _ := cache.GetWithContextCache(ctx, cachegroup.EmailAvatarLink, email, func(ctx context.Context, email string) (string, error) { - u, err := user_model.GetUserByEmail(ctx, email) - if err != nil { - if !user_model.IsErrUserNotExist(err) { - log.Error("GetUserByEmail: %v", err) - return "", err - } - return avatars.GenerateEmailAvatarFastLink(ctx, email, size), nil - } - return u.AvatarLinkWithSize(ctx, size), nil - }) - - return v -} - // CommitToPushCommit transforms a git.Commit to PushCommit type. func CommitToPushCommit(commit *git.Commit) *PushCommit { return &PushCommit{ diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index c0f00337b9..5e6266d9f2 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -4,14 +4,12 @@ package repository import ( - "strconv" "testing" "time" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" "gitea.dev/modules/git" - "gitea.dev/modules/setting" "github.com/stretchr/testify/assert" ) @@ -99,38 +97,6 @@ func TestPushCommits_ToAPIPayloadCommits(t *testing.T) { assert.Equal(t, []string{"readme.md"}, headCommit.Modified) } -func TestPushCommits_AvatarLink(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - pushCommits := NewPushCommits() - pushCommits.Commits = []*PushCommit{ - { - Sha1: "abcdef1", - CommitterEmail: "user2@example.com", - CommitterName: "User Two", - AuthorEmail: "user4@example.com", - AuthorName: "User Four", - Message: "message1", - }, - { - Sha1: "abcdef2", - CommitterEmail: "user2@example.com", - CommitterName: "User Two", - AuthorEmail: "user2@example.com", - AuthorName: "User Two", - Message: "message2", - }, - } - - assert.Equal(t, - "/avatars/ab53a2911ddf9b4817ac01ddcd3d975f?size="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor), - pushCommits.AvatarLink(t.Context(), "user2@example.com")) - - assert.Equal(t, - "/assets/img/avatar_default.png", - pushCommits.AvatarLink(t.Context(), "nonexistent@example.com")) -} - func TestCommitToPushCommit(t *testing.T) { now := time.Now() sig := &git.Signature{ diff --git a/modules/setting/config/value.go b/modules/setting/config/value.go index 655120c180..240f48243c 100644 --- a/modules/setting/config/value.go +++ b/modules/setting/config/value.go @@ -78,11 +78,16 @@ func isZeroOrEmpty(v any) bool { return false } +var SkipDatabaseConfig bool + func (opt *Option[T]) ValueRevision(ctx context.Context) (v T, rev int, has bool) { dg := GetDynGetter() if dg == nil { // this is an edge case: the database is not initialized but the system setting is going to be used // it should panic to avoid inconsistent config values (from config / system setting) and fix the code + if SkipDatabaseConfig { + return opt.DefaultValue(), 0, false + } panic("no config dyn value getter") } diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 81941902ae..46f90be78a 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -10,8 +10,10 @@ import ( "math" "net/url" "regexp" + "slices" "strings" + user_model "gitea.dev/models/gituser" issues_model "gitea.dev/models/issues" "gitea.dev/models/renderhelper" "gitea.dev/models/repo" @@ -22,6 +24,7 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/markup" "gitea.dev/modules/markup/markdown" + "gitea.dev/modules/repository" "gitea.dev/modules/reqctx" "gitea.dev/modules/setting" "gitea.dev/modules/svg" @@ -31,11 +34,12 @@ import ( ) type RenderUtils struct { - ctx reqctx.RequestContext + ctx reqctx.RequestContext + avatarUtils *AvatarUtils } func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils { - return &RenderUtils{ctx: ctx} + return &RenderUtils{ctx: ctx, avatarUtils: NewAvatarUtils(ctx)} } // RenderCommitMessage renders commit message title (only title) @@ -291,3 +295,134 @@ func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *chars } return `` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `` } + +func renderAvatarStackViewEmailLink(data *user_model.AvatarStackData, email string) template.URL { + if data.SearchByEmailLink != "" && email != "" { + return template.URL(strings.ReplaceAll(data.SearchByEmailLink, "{email}", url.QueryEscape(email))) + } + return "" +} + +func (ut *RenderUtils) participantHref(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.URL { + if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" { + return href + } + if participant.GiteaUser != nil { + return template.URL(participant.GiteaUser.HomeLink()) + } else if participant.GitIdentity.Email != "" { + return template.URL("mailto:" + participant.GitIdentity.Email) + } + return "" +} + +func (ut *RenderUtils) participantAvatar(participant *user_model.CommitParticipant) template.HTML { + if participant.GiteaUser != nil { + return ut.avatarUtils.Avatar(participant.GiteaUser, 20) + } + return ut.avatarUtils.AvatarByEmail(participant.GitIdentity.Email, participant.GitIdentity.Name, 20) +} + +func participantName(participant *user_model.CommitParticipant) string { + if participant.GiteaUser != nil { + return participant.GiteaUser.GetDisplayName() + } + return participant.GitIdentity.Name +} + +const renderAvatarStackMaxVisible = 10 + +// AvatarStack renders overlapping avatars for the stack participants. It emits children in reverse +// so CSS `flex-direction: row-reverse` places the primary (Participants[0]) leftmost and last-painted (on top). +func (ut *RenderUtils) AvatarStack(data *user_model.AvatarStackData) template.HTML { + visible := data.Participants + overflow := len(visible) - renderAvatarStackMaxVisible + if overflow > 0 { + visible = visible[:renderAvatarStackMaxVisible] + } + + var b htmlutil.HTMLBuilder + b.WriteHTML(``) + if overflow > 0 { + b.WriteFormat(`+%d`, overflow, overflow) + } + + // FIXME: such "backward" breaks a11y like screen readers + for _, participant := range slices.Backward(visible) { + ut.writeAvatarStackItem(&b, data, participant) + } + b.WriteHTML(``) + return b.HTMLString() +} + +func (ut *RenderUtils) writeAvatarStackItem(b *htmlutil.HTMLBuilder, data *user_model.AvatarStackData, participant *user_model.CommitParticipant) { + avatar := ut.participantAvatar(participant) + if href := ut.participantHref(data, participant); href != "" { + b.WriteFormat(`%s`, href, avatar) + } else { + b.WriteFormat(`%s`, avatar) + } +} + +func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit) template.HTML { + fakeGitCommit := git.Commit{ + CommitMessage: git.CommitMessage{MessageRaw: pushCommit.Message}, + Author: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail}, + // there is no way to know the real committer, but the field can't be nil + Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail}, + } + data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil) + return ut.AvatarStack(data) +} + +// AvatarStackWithNames renders the avatar stack plus a label: `name` / `a and b` / `N people` (opens popup). +func (ut *RenderUtils) AvatarStackWithNames(data *user_model.AvatarStackData) template.HTML { + locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) + participants := data.Participants + + var b htmlutil.HTMLBuilder + b.WriteHTML(``) + b.WriteHTML(ut.AvatarStack(data)) + + switch len(participants) { + case 1: + b.WriteHTML(ut.participantNameLink(data, participants[0])) + case 2: + b.WriteHTML(ut.participantNameLink(data, participants[0])) + b.WriteFormat(`%s`, locale.Tr("repo.commits.avatar_stack_and")) + b.WriteHTML(ut.participantNameLink(data, participants[1])) + default: + b.WriteFormat(``, + locale.Tr("repo.commits.avatar_stack_people", len(participants))) + b.WriteHTML(`
`) + for _, participant := range participants { + b.WriteHTML(ut.participantPopupRow(data, participant)) + } + b.WriteHTML(`
`) + } + + b.WriteHTML(`
`) + return b.HTMLString() +} + +// participantNameLink prefers (in order): commits-by-author search, `GetShortDisplayNameLinkHTML` (keeps alt-name tooltip), `mailto:`, bare name. +func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML { + if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" { + return htmlutil.HTMLFormat(`%s`, href, participantName(participant)) + } + if participant.GiteaUser != nil { + return participant.GiteaUser.GetShortDisplayNameLinkHTML() + } + if participant.GitIdentity.Email != "" { + return htmlutil.HTMLFormat(`%s`, participant.GitIdentity.Email, participant.GitIdentity.Name) + } + return template.HTML(template.HTMLEscapeString(participant.GitIdentity.Name)) +} + +func (ut *RenderUtils) participantPopupRow(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML { + avatar := ut.participantAvatar(participant) + name := participantName(participant) + if href := ut.participantHref(data, participant); href != "" { + return htmlutil.HTMLFormat(`%s%s`, href, avatar, name) + } + return htmlutil.HTMLFormat(`%s%s`, avatar, name) +} diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 5a28a1feba..1db87feb79 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -7,15 +7,19 @@ import ( "context" "html/template" "os" + "strconv" "strings" "testing" + "gitea.dev/models/gituser" "gitea.dev/models/issues" "gitea.dev/models/repo" user_model "gitea.dev/models/user" + "gitea.dev/modules/git" "gitea.dev/modules/markup" "gitea.dev/modules/reqctx" "gitea.dev/modules/setting" + "gitea.dev/modules/setting/config" "gitea.dev/modules/test" "gitea.dev/modules/translation" @@ -298,3 +302,52 @@ func TestUserMention(t *testing.T) { rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user") assert.Equal(t, `

@no-such-user @mention-user @mention-user

`, strings.TrimSpace(string(rendered))) } + +func TestAvatarStack(t *testing.T) { + defer test.MockVariableValue(&config.SkipDatabaseConfig, true)() + + ut := newTestRenderUtils(t) + mkCo := func(name, email string) *git.CommitIdentity { + return &git.CommitIdentity{Name: name, Email: email} + } + authorSig := mkCo("Alice", "alice@example.com") + mkData := func(co ...*git.CommitIdentity) *gituser.AvatarStackData { + all := append([]*git.CommitIdentity{authorSig}, co...) + return gituser.BuildAvatarStackData(t.Context(), all, &user_model.EmailUserMap{}) + } + + t.Run("lone author renders bare name, no label", func(t *testing.T) { + got := string(ut.AvatarStackWithNames(mkData())) + assert.Contains(t, got, ``) + assert.Contains(t, got, "Alice") + assert.NotContains(t, got, "avatar_stack_and") + assert.NotContains(t, got, "avatar_stack_people") + }) + + t.Run("two participants use and label", func(t *testing.T) { + got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com")))) + assert.Contains(t, got, "repo.commits.avatar_stack_and") + assert.Contains(t, got, "Bob") + assert.NotContains(t, got, "avatar_stack_people") + assert.Contains(t, got, ``) + }) + + t.Run("three participants switch to N people label with tippy popup", func(t *testing.T) { + got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"), mkCo("Carol", "carol@example.com")))) + assert.Contains(t, got, "repo.commits.avatar_stack_people:3") + assert.NotContains(t, got, "repo.commits.avatar_stack_and") + assert.Contains(t, got, `data-global-init="initAvatarStackPopup"`) + assert.Contains(t, got, `
`) + assert.Contains(t, got, `class="avatar-stack-popup"`) + }) + + t.Run("overflow chip renders beyond 10 participants", func(t *testing.T) { + cos := make([]*git.CommitIdentity, 0, renderAvatarStackMaxVisible+1) + for i := range renderAvatarStackMaxVisible + 1 { + cos = append(cos, mkCo("X", strconv.Itoa(i)+"@example.com")) + } + got := ut.AvatarStack(gituser.BuildAvatarStackData(t.Context(), cos, &user_model.EmailUserMap{})) + assert.Contains(t, got, `class="avatar-stack-overflow-chip`) + assert.Contains(t, got, "+1") + }) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9595baebed..d9139f17e3 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2205,10 +2205,10 @@ "repo.settings.trust_model.collaborator.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\", whether they match the committer or not. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" if not.", "repo.settings.trust_model.committer": "Committer", "repo.settings.trust_model.committer.long": "Committer: Trust signatures that match committers. This matches GitHub's behavior and will force commits signed by Gitea to have Gitea as the committer.", - "repo.settings.trust_model.committer.desc": "Valid signatures will only be marked \"trusted\" if they match the committer, otherwise they will be marked \"unmatched\". This forces Gitea to be the committer on signed commits, with the actual committer marked as Co-authored-by: and Co-committed-by: trailer in the commit. The default Gitea key must match a user in the database.", + "repo.settings.trust_model.committer.desc": "Valid signatures will only be marked \"trusted\" if they match the committer, otherwise they will be marked \"unmatched\". This forces Gitea to be the committer on signed commits, with the actual committer marked as a Co-authored-by: trailer in the commit. The default Gitea key must match a user in the database.", "repo.settings.trust_model.collaboratorcommitter": "Collaborator+Committer", "repo.settings.trust_model.collaboratorcommitter.long": "Collaborator+Committer: Trust signatures by collaborators which match the committer", - "repo.settings.trust_model.collaboratorcommitter.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\" if they match the committer. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" otherwise. This will force Gitea to be marked as the committer on signed commits, with the actual committer marked as Co-Authored-By: and Co-Committed-By: trailer in the commit. The default Gitea key must match a user in the database.", + "repo.settings.trust_model.collaboratorcommitter.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\" if they match the committer. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" otherwise. This will force Gitea to be marked as the committer on signed commits, with the actual committer marked as a Co-Authored-By: trailer in the commit. The default Gitea key must match a user in the database.", "repo.settings.wiki_delete": "Delete Wiki Data", "repo.settings.wiki_delete_desc": "Deleting repository wiki data is permanent and cannot be undone.", "repo.settings.wiki_delete_notices_1": "- This will permanently delete and disable the repository wiki for %s.", @@ -2599,6 +2599,9 @@ "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.avatar_stack_and": "and", + "repo.commits.avatar_stack_people": "%d people", "repo.diff.protected": "Protected", "repo.diff.image.side_by_side": "Side by Side", "repo.diff.image.swipe": "Swipe", diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index 87c1ffdb2a..294bd7e1cb 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -15,6 +15,7 @@ import ( "gitea.dev/models/asymkey" "gitea.dev/models/db" + "gitea.dev/models/gituser" user_model "gitea.dev/models/user" "gitea.dev/modules/badge" "gitea.dev/modules/charset" @@ -61,8 +62,8 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) { mockUser := mockUsers[0] commits = append(commits, &asymkey.SignCommit{ Verification: &asymkey.CommitVerification{}, - UserCommit: &user_model.UserCommit{ - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + UserCommit: &gituser.UserCommit{ + GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, }, }) commits = append(commits, &asymkey.SignCommit{ @@ -73,9 +74,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) { SigningKey: &asymkey.GPGKey{KeyID: "12345678"}, TrustStatus: "trusted", }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + UserCommit: &gituser.UserCommit{ + AuthorUser: mockUser, + GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, }, }) commits = append(commits, &asymkey.SignCommit{ @@ -86,9 +87,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) { SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, TrustStatus: "untrusted", }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + UserCommit: &gituser.UserCommit{ + AuthorUser: mockUser, + GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, }, }) commits = append(commits, &asymkey.SignCommit{ @@ -99,9 +100,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) { SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, TrustStatus: "other(unmatch)", }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + UserCommit: &gituser.UserCommit{ + AuthorUser: mockUser, + GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, }, }) commits = append(commits, &asymkey.SignCommit{ @@ -110,9 +111,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) { Reason: "gpg.error", SigningEmail: "test@example.com", }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + UserCommit: &gituser.UserCommit{ + AuthorUser: mockUser, + GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, }, }) @@ -159,6 +160,59 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) { ctx.Data["SelectedStyle"] = selectedStyle } +func prepareMockDataAvatarStack(ctx *context.Context) { + /* + mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 3}}) + if len(mockUsers) == 0 { + return + } + u0 := mockUsers[0] + u1, u2 := u0, u0 + if len(mockUsers) >= 2 { + u1 = mockUsers[1] + } + if len(mockUsers) >= 3 { + u2 = mockUsers[2] + } + + authorSig := func(u *user_model.User) *git.Signature { + return &git.Signature{Name: u.Name, Email: u.Email} + } + coLinked := func(u *user_model.User) *gituser.CommitParticipant { + return &gituser.CommitParticipant{GiteaUser: u, GitIdentity: authorSig(u)} + } + coUnlinked := func(name, email string) *gituser.CommitParticipant { + return &gituser.CommitParticipant{GitIdentity: &git.Signature{Name: name, Email: email}} + } + nUnlinked := func(n int) []*gituser.CommitParticipant { + out := make([]*gituser.CommitParticipant, n) + for i := range out { + out[i] = coUnlinked(fmt.Sprintf("Contributor %d", i+1), fmt.Sprintf("contrib%d@example.com", i+1)) + } + return out + } + + type scenario struct { + Label string + Data *gituser.AvatarStackData + } + mk := gituser.BuildAvatarStackData() + extSig := &git.Signature{Name: "External Contributor", Email: "external@example.com"} + ctx.Data["AvatarStackScenarios"] = []scenario{ + {Label: "linked author, no co-authors", Data: mk(u0, authorSig(u0), nil)}, + {Label: "unlinked author, no co-authors", Data: mk(nil, extSig, nil)}, + {Label: "1 linked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1)})}, + {Label: "1 unlinked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coUnlinked("Bob Smith", "bob@example.com")})}, + {Label: "2 co-authors (3 people), u1 author", Data: mk(u1, authorSig(u1), []*gituser.CommitParticipant{coLinked(u0), coUnlinked("Bob Smith", "bob@example.com")})}, + {Label: "3 co-authors mixed (4 people)", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1), coLinked(u2), coUnlinked("Bob Smith", "bob@example.com")})}, + {Label: "9 co-authors (max visible, no overflow), u2 author", Data: mk(u2, authorSig(u2), nUnlinked(9))}, + {Label: "10 co-authors (overflow +1)", Data: mk(u0, authorSig(u0), nUnlinked(10))}, + {Label: "15 co-authors (overflow +6), unlinked author", Data: mk(nil, extSig, nUnlinked(15))}, + {Label: "30 co-authors (overflow +21)", Data: mk(u0, authorSig(u0), nUnlinked(30))}, + } + */ +} + func prepareMockDataRelativeTime(ctx *context.Context) { now := time.Now() ctx.Data["TimeNow"] = now @@ -196,6 +250,8 @@ func prepareMockData(ctx *context.Context) { prepareMockDataToastAndMessage(ctx) case "/devtest/unicode-escape": prepareMockDataUnicodeEscape(ctx) + case "/devtest/avatar-stack": + prepareMockDataAvatarStack(ctx) } } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 6b97a58c17..457f9795a2 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -12,8 +12,8 @@ import ( "path" "strconv" + "gitea.dev/models/gituser" repo_model "gitea.dev/models/repo" - user_model "gitea.dev/models/user" "gitea.dev/modules/charset" "gitea.dev/modules/git" "gitea.dev/modules/git/languagestats" @@ -29,13 +29,14 @@ import ( type blameRow struct { RowNumber int - Avatar template.HTML PreviousSha string PreviousShaURL string CommitURL string CommitMessage string CommitSince template.HTML + AvatarStackData *gituser.AvatarStackData + Code template.HTML EscapeStatus *charset.EscapeStatus } @@ -174,9 +175,9 @@ func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error { return nil } -func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit { +func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*gituser.UserCommit { // store commit data by SHA to look up avatar info etc - commitNames := make(map[string]*user_model.UserCommit) + commitNames := make(map[string]*gituser.UserCommit) // and as blameParts can reference the same commits multiple // times, we cache the lookup work locally commits := make([]*git.Commit, 0, len(blameParts)) @@ -209,33 +210,28 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma } // populate commit email addresses to later look up avatars. - validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits) + userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, ctx.Repo.RepoLink, ctx.Repo.RefFullName) if err != nil { - ctx.ServerError("ValidateCommitsWithEmails", err) + ctx.ServerError("GetUserCommitsByGitCommits", err) return nil } - for _, c := range validatedCommits { - commitNames[c.ID.String()] = c + for _, c := range userCommits { + commitNames[c.GitCommit.ID.String()] = c } return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { - if commit.User != nil { - br.Avatar = avatarUtils.Avatar(commit.User, 18) - } else { - br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18) - } - +func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) { + br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil) br.PreviousSha = part.PreviousSha br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) - br.CommitMessage = commit.MessageUTF8() - br.CommitSince = templates.TimeSince(commit.Author.When) + br.CommitMessage = commit.GitCommit.MessageUTF8() + br.CommitSince = templates.TimeSince(commit.GitCommit.Author.When) } -func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) { +func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*gituser.UserCommit) { language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) if err != nil { log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) @@ -243,7 +239,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa buf := &bytes.Buffer{} rows := make([]*blameRow, 0) - avatarUtils := templates.NewAvatarUtils(ctx) rowNumber := 0 // will be 1-based for _, part := range blameParts { for partLineIdx, line := range part.Lines { @@ -258,7 +253,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa } if partLineIdx == 0 { - renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br) + renderBlameFillFirstBlameRow(ctx, ctx.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index ff3496629f..d895387818 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -14,6 +14,7 @@ import ( asymkey_model "gitea.dev/models/asymkey" "gitea.dev/models/db" git_model "gitea.dev/models/git" + "gitea.dev/models/gituser" issues_model "gitea.dev/models/issues" "gitea.dev/models/renderhelper" repo_model "gitea.dev/models/repo" @@ -49,7 +50,7 @@ func RefCommits(ctx *context.Context) { switch { case len(ctx.Repo.TreePath) == 0: Commits(ctx) - case ctx.Repo.TreePath == "search": + case ctx.Repo.TreePath == "search": // FIXME: legacy dirty design, it conflicts with the FileHistory SearchCommits(ctx) default: FileHistory(ctx) @@ -396,7 +397,8 @@ func Diff(ctx *context.Context) { verification := asymkey_service.ParseCommitWithSignature(ctx, commit) ctx.Data["Verification"] = verification - ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit) + ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:] ctx.Data["Parents"] = parents ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 @@ -411,7 +413,7 @@ func Diff(ctx *context.Context) { err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note) if err == nil { ctx.Data["NoteCommit"] = note.Commit - ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) + ctx.Data["NoteAuthor"] = user_model.GetUserByGitAuthor(ctx, note.Commit) rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)}) htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) ctx.Data["NoteRendered"] = markup.PostProcessCommitMessage(rctx, htmlMessage) @@ -461,7 +463,7 @@ func RawDiff(ctx *context.Context) { } func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) { - commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository) + commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository, ctx.Repo.RefFullName) if err != nil { return nil, err } diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 2b2848afd1..45735fc8fe 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -391,14 +391,14 @@ func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*g if useFirstCommitAsTitle { // the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one c := commits[len(commits)-1] - title = c.UserCommit.MessageTitle() + title = c.UserCommit.GitCommit.MessageTitle() } else { title = autoTitleFromBranchName(ci.HeadRef.ShortName()) } if len(commits) == 1 { c := commits[0] - content = c.MessageBody() + content = c.GitCommit.MessageBody() } var titleTrailer string diff --git a/routers/web/repo/compare_test.go b/routers/web/repo/compare_test.go index d5b67ebe56..af0a735227 100644 --- a/routers/web/repo/compare_test.go +++ b/routers/web/repo/compare_test.go @@ -9,8 +9,8 @@ import ( asymkey_model "gitea.dev/models/asymkey" git_model "gitea.dev/models/git" + "gitea.dev/models/gituser" issues_model "gitea.dev/models/issues" - user_model "gitea.dev/models/user" "gitea.dev/modules/git" "gitea.dev/modules/setting" git_service "gitea.dev/services/git" @@ -52,8 +52,8 @@ func TestNewPullRequestTitleContent(t *testing.T) { mockCommit := func(msg string) *git_model.SignCommitWithStatuses { return &git_model.SignCommitWithStatuses{ SignCommit: &asymkey_model.SignCommit{ - UserCommit: &user_model.UserCommit{ - Commit: &git.Commit{ + UserCommit: &gituser.UserCommit{ + GitCommit: &git.Commit{ CommitMessage: git.CommitMessage{MessageRaw: msg}, }, }, diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 437eedc57f..6df4e738c9 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -21,6 +21,7 @@ import ( asymkey_model "gitea.dev/models/asymkey" "gitea.dev/models/db" git_model "gitea.dev/models/git" + "gitea.dev/models/gituser" repo_model "gitea.dev/models/repo" unit_model "gitea.dev/models/unit" user_model "gitea.dev/models/user" @@ -132,8 +133,11 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { ctx.ServerError("CalculateTrustStatus", err) return false } + + avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllParticipantIdentities(), nil) + avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName) + ctx.Data["LatestCommitAvatarStackData"] = avatarStackData ctx.Data["LatestCommitVerification"] = verification - ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) if err != nil { diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index e0881dc9f1..6e2133a945 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -361,7 +361,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) ctx.ServerError("CommitsByFileAndRange", err) return nil, nil } - ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) + ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository, "") // no current ref sub path for wiki commit list if err != nil { ctx.ServerError("ConvertFromGitCommit", err) return nil, nil diff --git a/services/git/commit.go b/services/git/commit.go index 5708e162b9..bc0516b5d6 100644 --- a/services/git/commit.go +++ b/services/git/commit.go @@ -9,6 +9,7 @@ import ( asymkey_model "gitea.dev/models/asymkey" "gitea.dev/models/db" git_model "gitea.dev/models/git" + "gitea.dev/models/gituser" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/container" @@ -17,14 +18,14 @@ import ( ) // ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys. -func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) { +func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*gituser.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) { newCommits := make([]*asymkey_model.SignCommit, 0, len(oldCommits)) keyMap := map[string]bool{} emails := make(container.Set[string]) for _, c := range oldCommits { - if c.Committer != nil { - emails.Add(c.Committer.Email) + if c.GitCommit.Committer != nil { + emails.Add(c.GitCommit.Committer.Email) } } @@ -34,10 +35,10 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, } for _, c := range oldCommits { - committerUser := emailUsers.GetByEmail(c.Committer.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? + committerUser := emailUsers.GetByEmail(c.GitCommit.Committer.Email) // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"? signCommit := &asymkey_model.SignCommit{ UserCommit: c, - Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.Commit, committerUser), + Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.GitCommit, committerUser), } isOwnerMemberCollaborator := func(user *user_model.User) (bool, error) { @@ -52,15 +53,15 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, } // ConvertFromGitCommit converts git commits into SignCommitWithStatuses -func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository) ([]*git_model.SignCommitWithStatuses, error) { - validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits) +func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository, currentRef git.RefName) ([]*git_model.SignCommitWithStatuses, error) { + userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, repo.Link(), currentRef) if err != nil { return nil, err } signedCommits, err := ParseCommitsWithSignature( ctx, repo, - validatedCommits, + userCommits, repo.GetTrustModel(), ) if err != nil { @@ -77,7 +78,7 @@ func ParseCommitsWithStatus(ctx context.Context, oldCommits []*asymkey_model.Sig commit := &git_model.SignCommitWithStatuses{ SignCommit: c, } - statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.ID.String(), db.ListOptionsAll) + statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.GitCommit.ID.String(), db.ListOptionsAll) if err != nil { return nil, err } diff --git a/services/issue/comments.go b/services/issue/comments.go index 46964f5f1a..ff6c9fccf5 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -184,7 +184,7 @@ func LoadCommentPushCommits(ctx context.Context, c *issues_model.Comment) error } defer closer.Close() - c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo) + c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo, "") // no current ref sub path for PR commit list if err != nil { log.Debug("ConvertFromGitCommit: %v", err) // no need to show 500 error to end user when the commit does not exist } else { diff --git a/services/pull/merge_squash.go b/services/pull/merge_squash.go index 67ec5bb81c..229730257f 100644 --- a/services/pull/merge_squash.go +++ b/services/pull/merge_squash.go @@ -65,9 +65,7 @@ func doMergeStyleSquash(ctx *mergeContext, message string) error { } if setting.Repository.PullRequest.AddCoCommitterTrailers && ctx.committer.String() != sig.String() { - // add trailer - message = AddCommitMessageTailer(message, "Co-authored-by", sig.String()) - message = AddCommitMessageTailer(message, "Co-committed-by", sig.String()) // FIXME: this one should be removed, it is not really used or widely used + message = AddCommitMessageTailer(message, git.CoAuthoredByTrailer, sig.String()) } cmdCommit := gitcmd.NewCommand("commit"). AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email). diff --git a/services/pull/pull.go b/services/pull/pull.go index 0d4a10ae9e..a601450ff2 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -917,7 +917,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ } for _, author := range authors { - stringBuilder.WriteString("Co-authored-by: ") + stringBuilder.WriteString(git.CoAuthoredByTrailer + ": ") stringBuilder.WriteString(author) stringBuilder.WriteRune('\n') } diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index b36f5f192a..553f4232e2 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -300,12 +300,7 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit cmdCommitTree.AddOptionFormat("-S%s", key.KeyID) if t.repo.GetTrustModel() == repo_model.CommitterTrustModel || t.repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel { if committerSig.Name != authorSig.Name || committerSig.Email != authorSig.Email { - // Add trailers - _, _ = messageBytes.WriteString("\n") - _, _ = messageBytes.WriteString("Co-authored-by: ") - _, _ = messageBytes.WriteString(committerSig.String()) - _, _ = messageBytes.WriteString("\n") - _, _ = messageBytes.WriteString("Co-committed-by: ") + _, _ = messageBytes.WriteString("\n" + git.CoAuthoredByTrailer + ": ") _, _ = messageBytes.WriteString(committerSig.String()) _, _ = messageBytes.WriteString("\n") } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index 82092f71f3..99f8222ca7 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -13,8 +13,10 @@ import ( asymkey_model "gitea.dev/models/asymkey" "gitea.dev/models/db" git_model "gitea.dev/models/git" + "gitea.dev/models/gituser" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" + "gitea.dev/modules/container" "gitea.dev/modules/git" "gitea.dev/modules/log" asymkey_service "gitea.dev/services/asymkey" @@ -93,9 +95,7 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error // before finally retrieving the latest status func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { var err error - var ok bool - - emails := map[string]*user_model.User{} + emailSet := make(container.Set[string]) keyMap := map[string]bool{} for _, c := range graph.Commits { @@ -106,14 +106,26 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_ if err != nil { return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err) } - if c.Commit.Author != nil { - email := c.Commit.Author.Email - if c.User, ok = emails[email]; !ok { - c.User, _ = user_model.GetUserByEmail(ctx, email) - emails[email] = c.User - } + emailSet.Add(c.Commit.Author.Email) } + for _, sig := range c.Commit.AllParticipantIdentities() { + emailSet.Add(sig.Email) + } + } + + emailUserMap, err := user_model.GetUsersByEmails(ctx, emailSet.Values()) + if err != nil { + log.Error("GetUsersByEmails: %v", err) + } + + for _, c := range graph.Commits { + if c.Commit == nil { + continue + } + + c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) + c.AvatarStackData = gituser.BuildAvatarStackData(ctx, c.Commit.AllParticipantIdentities(), emailUserMap) c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit) @@ -246,18 +258,19 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { // Commit represents a commit at coordinate X, Y with the data type Commit struct { - Commit *git.Commit - User *user_model.User - Verification *asymkey_model.CommitVerification - Status *git_model.CommitStatus - Flow int64 - Row int - Column int - Refs []git.Reference - Rev string - Date time.Time - ShortRev string - Subject string + Commit *git.Commit + User *user_model.User // author + AvatarStackData *gituser.AvatarStackData + Verification *asymkey_model.CommitVerification + Status *git_model.CommitStatus + Flow int64 + Row int + Column int + Refs []git.Reference + Rev string + Date time.Time // author date from "%ad" + ShortRev string + Subject string } // OnlyRelation returns whether this a relation only commit diff --git a/templates/devtest/avatar-stack.tmpl b/templates/devtest/avatar-stack.tmpl new file mode 100644 index 0000000000..12d1ff953b --- /dev/null +++ b/templates/devtest/avatar-stack.tmpl @@ -0,0 +1,18 @@ +{{template "devtest/devtest-header"}} +
+
+

Avatar Stack

+ + + + {{range $s := .AvatarStackScenarios}} + + + + + {{end}} + +
ScenarioRendered
{{$s.Label}}{{ctx.RenderUtils.AvatarStackWithNames $s.Data}}
+
+
+{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/badge-commit-sign.tmpl b/templates/devtest/badge-commit-sign.tmpl index a6677c4495..8cfb63a083 100644 --- a/templates/devtest/badge-commit-sign.tmpl +++ b/templates/devtest/badge-commit-sign.tmpl @@ -4,7 +4,7 @@

Commit Sign Badges

{{range $commit := .MockCommits}}
- {{template "repo/commit_sign_badge" dict "Commit" $commit "CommitBaseLink" "/devtest/commit" "CommitSignVerification" $commit.Verification}} + {{template "repo/commit_sign_badge" dict "Commit" $commit.GitCommit "CommitBaseLink" "/devtest/commit" "CommitSignVerification" $commit.Verification}} {{template "repo/commit_sign_badge" dict "CommitSignVerification" $commit.Verification}}
{{end}} diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 8bdefa5d43..d108ea3379 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{$row.Avatar}} + {{if $row.AvatarStackData}}{{ctx.RenderUtils.AvatarStack $row.AvatarStackData}}{{end}}
+ {{if .CommitOtherParticipants}} +
+ {{ctx.Locale.Tr "repo.diff.coauthored_by"}} + {{range $participant := .CommitOtherParticipants}} + {{$user := $participant.GiteaUser}} + {{$gitIdentity := $participant.GitIdentity}} + {{if $user}} + {{ctx.AvatarUtils.Avatar $user 20}} + {{$user.GetDisplayName}} + {{else}} + {{$gitName := $gitIdentity.Name}} + {{$gitEmail := $gitIdentity.Email}} + {{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}} + {{if $gitEmail}} + {{$gitName}} + {{else}} + {{$gitName}} + {{end}} + {{end}} + {{end}} +
+ {{end}} + {{if .Verification}} {{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}} {{end}} diff --git a/templates/repo/commit_sign_badge.tmpl b/templates/repo/commit_sign_badge.tmpl index f63e4ec899..bf8185fd0b 100644 --- a/templates/repo/commit_sign_badge.tmpl +++ b/templates/repo/commit_sign_badge.tmpl @@ -64,10 +64,10 @@ so this template should be kept as small as possible, DO NOT put large component {{- if $verified -}} {{- if and $signingUser $signingUser.ID -}} {{svg "gitea-lock"}} - {{ctx.AvatarUtils.Avatar $signingUser 16}} + {{ctx.AvatarUtils.Avatar $signingUser 20}} {{- else -}} {{svg "gitea-lock-cog"}} - {{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 16}} + {{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 20}} {{- end -}} {{- else -}} {{svg "gitea-unlock"}} diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index e79d189b8d..7a99bc7ac2 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -2,27 +2,21 @@ - + - + {{$commitRepoLink := $.RepoLink}}{{if $.CommitRepoLink}}{{$commitRepoLink = $.CommitRepoLink}}{{end}} - {{range $commit := .Commits}} + {{range $commit := $.Commits}} + {{$gitCommit := $commit.GitCommit}} + {{$commitID := $gitCommit.ID.String}} - {{if .Committer}} - - {{else}} - - {{end}} +
{{ctx.Locale.Tr "repo.commits.author"}}{{ctx.Locale.Tr "repo.commits.author"}} {{StringUtils.ToUpper $.Repository.ObjectFormatName}}{{ctx.Locale.Tr "repo.commits.message"}}{{ctx.Locale.Tr "repo.commits.message"}} {{ctx.Locale.Tr "repo.commits.date"}}
- - {{- 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 -}} - + {{ctx.RenderUtils.AvatarStackWithNames $commit.AvatarStackData}} {{$commitBaseLink := ""}} @@ -33,52 +27,48 @@ {{else}} {{$commitBaseLink = printf "%s/commit" $commitRepoLink}} {{end}} - {{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}} + {{template "repo/commit_sign_badge" dict "Commit" $gitCommit "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}} {{if $.PageIsWiki}} - - {{$commit.MessageTitle | ctx.RenderUtils.RenderEmoji}} + + {{$gitCommit.MessageTitle | ctx.RenderUtils.RenderEmoji}} {{else}} - {{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commit.ID.String)}} - - {{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.Repository}} + {{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commitID)}} + + {{ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.Repository}} {{end}} - {{if $commit.MessageBody}} + {{if $gitCommit.MessageBody}} {{end}} {{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}} - {{if $commit.MessageBody}} -
{{ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.Repository}}
+ {{if $gitCommit.MessageBody}} +
{{ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.Repository}}
{{end}} {{if $.CommitsTagsMap}} - {{range (index $.CommitsTagsMap .ID.String)}} + {{range (index $.CommitsTagsMap $commitID)}} {{- template "repo/tag/name" dict "AdditionalClasses" "tw-py-0" "RepoLink" $.Repository.Link "TagName" .TagName "IsRelease" (not .IsTag) -}} {{end}} {{end}}
{{DateUtils.TimeSince .Committer.When}}{{DateUtils.TimeSince .Author.When}}{{DateUtils.TimeSince $gitCommit.Committer.When}} - + {{/* at the moment, wiki doesn't support these "view" links like "view at history point" */}} {{if not $.PageIsWiki}} {{/* view single file diff */}} {{if $.FileTreePath}} {{svg "octicon-file-diff"}} {{end}} {{/* view at history point */}} - {{$viewCommitLink := printf "%s/src/commit/%s" $commitRepoLink (PathEscape .ID.String)}} + {{$viewCommitLink := printf "%s/src/commit/%s" $commitRepoLink (PathEscape $commitID)}} {{if $.FileTreePath}}{{$viewCommitLink = printf "%s/%s" $viewCommitLink (PathEscapeSegments $.FileTreePath)}}{{end}} {{svg "octicon-file-code"}} {{end}} diff --git a/templates/repo/commits_list_small.tmpl b/templates/repo/commits_list_small.tmpl index c5f0d5b590..2dd6727e93 100644 --- a/templates/repo/commits_list_small.tmpl +++ b/templates/repo/commits_list_small.tmpl @@ -1,35 +1,32 @@ {{$index := 0}}
-{{range $commit := .comment.Commits}} +{{range $commit := $.comment.Commits}} + {{$gitCommit := $commit.GitCommit}} {{$tag := printf "%s-%d" $.comment.HashTag $index}} {{$index = Eval $index "+" 1}}
{{/*singular-commit*/}} {{svg "octicon-git-commit"}} - {{if .User}} - {{ctx.AvatarUtils.Avatar .User 20}} - {{else}} - {{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}} - {{end}} + {{ctx.RenderUtils.AvatarStack $commit.AvatarStackData}} {{$commitBaseLink := printf "%s/commit" $.comment.Issue.PullRequest.BaseRepo.Link}} - {{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}} + {{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape $gitCommit.ID.String)}} - - {{- ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}} + + {{- ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}} - {{if $commit.MessageBody}} + {{if $gitCommit.MessageBody}} {{end}} - {{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}} - {{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}} + {{template "repo/commit_statuses" dict "Status" $commit.Status "Statuses" $commit.Statuses}} + {{template "repo/commit_sign_badge" dict "Commit" $gitCommit "CommitBaseLink" $commitBaseLink "CommitSignVerification" $commit.Verification}}
- {{if $commit.MessageBody}} + {{if $gitCommit.MessageBody}}
-		{{- ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
+		{{- ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
 	
{{end}} {{end}} diff --git a/templates/repo/graph/commits.tmpl b/templates/repo/graph/commits.tmpl index d86f73fe65..b9288da911 100644 --- a/templates/repo/graph/commits.tmpl +++ b/templates/repo/graph/commits.tmpl @@ -41,14 +41,7 @@ - {{if $commit.User}} - {{ctx.AvatarUtils.Avatar $commit.User 18}} - {{$commit.User.GetShortDisplayNameLinkHTML}} - {{else}} - {{$gitUserName := $commit.Commit.Author.Name}} - {{ctx.AvatarUtils.AvatarByEmail $commit.Commit.Author.Email $gitUserName 18}} - {{$gitUserName}} - {{end}} + {{ctx.RenderUtils.AvatarStackWithNames $commit.AvatarStackData}} {{DateUtils.FullTime $commit.Date}} diff --git a/templates/repo/latest_commit.tmpl b/templates/repo/latest_commit.tmpl index c0518189b8..b2aebf0d42 100644 --- a/templates/repo/latest_commit.tmpl +++ b/templates/repo/latest_commit.tmpl @@ -2,15 +2,7 @@ {{if not .LatestCommit}} … {{else}} - - {{- if .LatestCommitUser -}} - {{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}} - {{.LatestCommitUser.GetShortDisplayNameLinkHTML}} - {{- else if .LatestCommit.Author -}} - {{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}} - {{.LatestCommit.Author.Name}} - {{- end -}} - + {{ctx.RenderUtils.AvatarStackWithNames .LatestCommitAvatarStackData}} {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}} diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index 9afb61887c..ab02522c20 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -89,10 +89,10 @@ {{$repo := .Repo}}
{{range $pushCommit := $push.Commits}} - {{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}} + {{$commitLink := printf "%s/commit/%s" $repoLink $pushCommit.Sha1}}
- - {{ShortSha .Sha1}} + {{ctx.RenderUtils.AvatarStackPushCommit $pushCommit}} + {{ShortSha $pushCommit.Sha1}} {{ctx.RenderUtils.RenderCommitMessage $pushCommit.Message $repo}} diff --git a/tests/integration/repo_commits_test.go b/tests/integration/repo_commits_test.go index 9335ef7065..b55e1372c9 100644 --- a/tests/integration/repo_commits_test.go +++ b/tests/integration/repo_commits_test.go @@ -38,11 +38,15 @@ func TestRepoCommits(t *testing.T) { doc.doc.Find("#commits-table .commit-id-short").Each(func(i int, s *goquery.Selection) { commits = append(commits, path.Base(s.AttrOr("href", ""))) }) - doc.doc.Find("#commits-table .author-wrapper a").Each(func(i int, s *goquery.Selection) { + doc.doc.Find("#commits-table .avatar-stack-names a.muted").Each(func(i int, s *goquery.Selection) { userHrefs = append(userHrefs, s.AttrOr("href", "")) }) assert.Equal(t, []string{"69554a64c1e6030f051e5c3f94bfbd773cd6a324", "27566bd5738fc8b4e3fef3c5e72cce608537bd95", "5099b81332712fe655e34e8dd63574f503f61811"}, commits) - assert.Equal(t, []string{"/user2", "/user21", "/user2"}, userHrefs) + assert.Equal(t, []string{ + "/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com", + "/user2/repo16/commits/branch/master/search?q=author%3Auser21%40example.com", + "/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com", + }, userHrefs) }) t.Run("LastCommit", func(t *testing.T) { @@ -50,9 +54,9 @@ func TestRepoCommits(t *testing.T) { resp := session.MakeRequest(t, req, http.StatusOK) doc := NewHTMLParser(t, resp.Body) commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "") - authorHref := doc.doc.Find(".latest-commit .author-wrapper a").AttrOr("href", "") + authorHref := doc.doc.Find(".latest-commit .avatar-stack-names a").AttrOr("href", "") assert.Equal(t, "/user2/repo16/commit/69554a64c1e6030f051e5c3f94bfbd773cd6a324", commitHref) - assert.Equal(t, "/user2", authorHref) + assert.Equal(t, "/user2/repo16/commits/branch/master/search?q=author%3Auser2%40example.com", authorHref) }) t.Run("CommitListNonExistingCommiter", func(t *testing.T) { @@ -65,7 +69,7 @@ func TestRepoCommits(t *testing.T) { doc := NewHTMLParser(t, resp.Body) commitHref := doc.doc.Find("#commits-table tr:first-child .commit-id-short").AttrOr("href", "") assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref) - authorElem := doc.doc.Find("#commits-table tr:first-child .author-wrapper") + authorElem := doc.doc.Find("#commits-table tr:first-child .avatar-stack-names") assert.Equal(t, "6543", strings.TrimSpace(authorElem.Text())) }) @@ -97,7 +101,7 @@ func TestRepoCommits(t *testing.T) { doc := NewHTMLParser(t, resp.Body) commitHref := doc.doc.Find(".latest-commit .commit-id-short").AttrOr("href", "") assert.Equal(t, "/user2/repo1/commit/985f0301dba5e7b34be866819cd15ad3d8f508ee", commitHref) - authorElem := doc.doc.Find(".latest-commit .author-wrapper") + authorElem := doc.doc.Find(".latest-commit .avatar-stack-names") assert.Equal(t, "6543", strings.TrimSpace(authorElem.Text())) }) } diff --git a/web_src/css/avatar.css b/web_src/css/avatar.css new file mode 100644 index 0000000000..3b34b8207f --- /dev/null +++ b/web_src/css/avatar.css @@ -0,0 +1,125 @@ +img.ui.avatar, +.ui.avatar img, +.ui.avatar svg { + border-radius: var(--border-radius); + object-fit: contain; + aspect-ratio: 1; +} + +.avatar-stack-names { + display: inline-flex; + align-items: center; + align-self: center; + gap: 4px; + white-space: nowrap; + vertical-align: middle; +} + +.avatar-stack-names > a.muted, +.avatar-stack-names > .avatar-stack-popup-trigger { + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +/* use semibold for latest commit author */ +.latest-commit .avatar-stack-names > a, +.latest-commit .avatar-stack-names > .avatar-stack-popup-trigger { + font-weight: var(--font-weight-semibold); +} + +/* template emits children reversed; row-reverse re-orders visually and keeps the author last-painted (on top) */ +.avatar-stack { + display: inline-flex; + align-items: center; + flex-direction: row-reverse; +} + +.avatar-stack > * { + margin-left: -16px; + transition: transform 0.15s ease, opacity 0.15s ease; + position: relative; + display: inline-flex; +} + +.avatar-stack > *:last-child { margin-left: 0; } +.avatar-stack > *:nth-last-child(2) { margin-left: -14px; } + +/* hover spreads via transform (no layout shift); positions count from visual-left = last DOM child = :nth-last-child */ +.avatar-stack:hover > *:nth-last-child(2) { transform: translateX(14px); } +.avatar-stack:hover > *:nth-last-child(3) { transform: translateX(30px); } +.avatar-stack:hover > *:nth-last-child(4) { transform: translateX(46px); } +.avatar-stack:hover > *:nth-last-child(5) { transform: translateX(62px); } +.avatar-stack:hover > *:nth-last-child(6) { transform: translateX(78px); } +.avatar-stack:hover > *:nth-last-child(7) { transform: translateX(94px); } +.avatar-stack:hover > *:nth-last-child(8) { transform: translateX(110px); } +.avatar-stack:hover > *:nth-last-child(9) { transform: translateX(126px); } +.avatar-stack:hover > *:nth-last-child(10) { transform: translateX(142px); } +.avatar-stack:hover > *:nth-last-child(11) { transform: translateX(158px); } + +.avatar-stack .avatar { + border: 1px solid var(--color-body); + background: var(--color-body); + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.avatar-stack:hover .avatar { + background-color: var(--color-body); +} + +.avatar-stack-overflow-chip { + align-items: center; + justify-content: center; + width: 0; + height: 20px; + margin-left: 0; + border: 0 solid var(--color-body); + border-radius: var(--border-radius); + color: var(--color-text); + font-weight: var(--font-weight-semibold); + overflow: hidden; + opacity: 0; + transition: all 0.15s ease; +} + +.avatar-stack:hover .avatar-stack-overflow-chip { + width: 20px; + margin-left: -16px; + border-width: 1px; + opacity: 1; +} + +.avatar-stack-popup-trigger { + cursor: pointer; + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; +} + +.avatar-stack-popup-trigger:hover { + color: var(--color-primary); +} + +.avatar-stack-popup { + min-width: 200px; + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.avatar-stack-popup > a { + padding: 6px 12px; + gap: 8px; +} + +.avatar-stack-popup > a:hover { + background: var(--color-hover); +} + +@media (max-width: 767.98px) { + .avatar-stack-names { + max-width: 80px; + } +} diff --git a/web_src/css/base.css b/web_src/css/base.css index 0716ad1913..8b8cfac2d1 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -386,14 +386,6 @@ a.label, color: var(--color-text-light-2); } -img.ui.avatar, -.ui.avatar img, -.ui.avatar svg { - border-radius: var(--border-radius); - object-fit: contain; - aspect-ratio: 1; -} - .full.height { flex-grow: 1; padding-bottom: var(--page-space-bottom); diff --git a/web_src/css/index.css b/web_src/css/index.css index a56982efcf..6d9280c67f 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -56,6 +56,7 @@ @import "./font_i18n.css"; @import "./base.css"; +@import "./avatar.css"; @import "./home.css"; @import "./install.css"; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index b7cb5e1dcd..0682290e1a 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1386,8 +1386,7 @@ tbody.commit-list { vertical-align: baseline; } -.message-wrapper, -.author-wrapper { +.message-wrapper { overflow: hidden; text-overflow: ellipsis; max-width: 100%; @@ -1395,12 +1394,6 @@ tbody.commit-list { vertical-align: middle; } -.author-wrapper { - max-width: 180px; - align-self: center; - white-space: nowrap; -} - .latest-commit .message-wrapper { max-width: calc(100% - 2.5rem); } @@ -1415,9 +1408,6 @@ tbody.commit-list { tr.commit-list { width: 100%; } - .author-wrapper { - max-width: 80px; - } } @media (min-width: 768px) and (max-width: 991.98px) { diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 17130427a6..6103766202 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -25,6 +25,22 @@ export function initCommitStatuses() { }); } +export function initAvatarStackPopup() { + registerGlobalInitFunc('initAvatarStackPopup', (el: HTMLElement) => { + const nextEl = el.nextElementSibling!; + if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target'); + createTippy(el, { + content: nextEl, + placement: 'bottom-start', + interactive: true, + role: 'dialog', + theme: 'menu', + trigger: 'click', + hideOnClick: true, + }); + }); +} + export function initCommitFileHistoryFollowRename() { registerGlobalInitFunc('initCommitHistoryFollowRename', (el: HTMLInputElement) => { el.addEventListener('change', () => { diff --git a/web_src/js/index.ts b/web_src/js/index.ts index a2994d6912..6f081f3020 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -21,7 +21,7 @@ import {initMarkupContent} from './markup/content.ts'; import {initRepoFileView} from './features/file-view.ts'; import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; -import {initRepoEllipsisButton, initCommitStatuses, initCommitFileHistoryFollowRename} from './features/repo-commit.ts'; +import {initRepoEllipsisButton, initCommitStatuses, initAvatarStackPopup, initCommitFileHistoryFollowRename} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminCommon} from './features/admin/common.ts'; import {initRepoCodeView} from './features/repo-code.ts'; @@ -146,6 +146,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoRecentCommits, initCommitStatuses, + initAvatarStackPopup, initCaptcha, initUserCheckAppUrl,