feat: Add avatar stacks (#37594)
Parse `Co-authored-by:` trailers from commit messages and surface contributors as an avatar stack across the commit page, commits list, PR commits tab, latest-commit row, blame, graph, and dashboard feed. - Up to 10 visible 20px avatars, GitHub-style overlap (6px first stride, 4px between subsequent), `+N` chip for the rest. - Label: 1 → name; 2 → `<a> and <b>`; 3+ → `<N> people` opens a Tippy popup with all participants. - Names and avatars link to the repo's commits-by-author search; fall back to profile or `mailto:`. - Trailer parsing uses `net/mail.ParseAddress`, scans only the trailing paragraph, filters out the commit's own author/committer. - Drops the non-standard `Co-committed-by:` emission on squash merge and web edits. Devtest: `/devtest/coauthor-avatars`. Fixes #25521 ---- <img width="353" height="277" alt="image" src="https://github.com/user-attachments/assets/72092ceb-97ca-4b09-9557-0b72d3c5458e" /> <img width="533" height="328" src="https://github.com/user-attachments/assets/11d0c8f8-8b3f-4f2e-9993-879f1c06bcc5" /> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
co-authored by
GitHub
Claude Opus 4.7
silverwind
wxiaoguang
Giteabot
parent
2a84831400
commit
54916f708e
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+2
-36
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<content>.*?)(?P<sep>^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*)$`)
|
||||
})
|
||||
|
||||
func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
s = util.NormalizeStringEOL(s)
|
||||
re := commitMessageTrailerSplit()
|
||||
v := re.FindStringSubmatch(s)
|
||||
if v == nil {
|
||||
return s, "", ""
|
||||
}
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
kLower := strings.ToLower(k)
|
||||
ret[kLower] = append(ret[kLower], v)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
if c.allParticipants != nil {
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
exclude := container.Set[string]{}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
|
||||
exclude.Add(strings.ToLower(c.Author.Email))
|
||||
|
||||
addParticipant := func(name, email string) {
|
||||
if name == "" && email == "" {
|
||||
return
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if emailLower != "" && exclude.Contains(emailLower) {
|
||||
return
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
|
||||
exclude.Add(emailLower)
|
||||
}
|
||||
addParticipant(c.Committer.Name, c.Committer.Email)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
if err == nil {
|
||||
addParticipant(addr.Name, addr.Address)
|
||||
} else {
|
||||
addParticipant(coAuthorValue, "")
|
||||
}
|
||||
}
|
||||
return c.allParticipants
|
||||
}
|
||||
@@ -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 <X@M.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<td class="lines-escape">` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `</td>`
|
||||
}
|
||||
|
||||
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(`<span class="avatar-stack">`)
|
||||
if overflow > 0 {
|
||||
b.WriteFormat(`<span class="avatar-stack-overflow-chip tw-text-xs" aria-label="+%d more">+%d</span>`, overflow, overflow)
|
||||
}
|
||||
|
||||
// FIXME: such "backward" breaks a11y like screen readers
|
||||
for _, participant := range slices.Backward(visible) {
|
||||
ut.writeAvatarStackItem(&b, data, participant)
|
||||
}
|
||||
b.WriteHTML(`</span>`)
|
||||
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(`<a href="%s">%s</a>`, href, avatar)
|
||||
} else {
|
||||
b.WriteFormat(`<span>%s</span>`, 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(`<span class="avatar-stack-names">`)
|
||||
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(`<span>%s</span>`, locale.Tr("repo.commits.avatar_stack_and"))
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[1]))
|
||||
default:
|
||||
b.WriteFormat(`<button type="button" class="avatar-stack-popup-trigger" data-global-init="initAvatarStackPopup">%s</button>`,
|
||||
locale.Tr("repo.commits.avatar_stack_people", len(participants)))
|
||||
b.WriteHTML(`<div class="tippy-target"><div class="avatar-stack-popup">`)
|
||||
for _, participant := range participants {
|
||||
b.WriteHTML(ut.participantPopupRow(data, participant))
|
||||
}
|
||||
b.WriteHTML(`</div></div>`)
|
||||
}
|
||||
|
||||
b.WriteHTML(`</span>`)
|
||||
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(`<a class="muted" href="%s">%s</a>`, href, participantName(participant))
|
||||
}
|
||||
if participant.GiteaUser != nil {
|
||||
return participant.GiteaUser.GetShortDisplayNameLinkHTML()
|
||||
}
|
||||
if participant.GitIdentity.Email != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="mailto:%s">%s</a>`, 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(`<a class="silenced flex-text-block" href="%s">%s<span>%s</span></a>`, href, avatar, name)
|
||||
}
|
||||
return htmlutil.HTMLFormat(`<span class="flex-text-block">%s<span>%s</span></span>`, avatar, name)
|
||||
}
|
||||
|
||||
@@ -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, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, 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, `<span class="avatar-stack-names">`)
|
||||
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, `<span class="avatar-stack">`)
|
||||
})
|
||||
|
||||
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, `<div class="tippy-target">`)
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-20
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h1>Avatar Stack</h1>
|
||||
<table class="ui basic table">
|
||||
<thead><tr><th>Scenario</th><th>Rendered</th></tr></thead>
|
||||
<tbody>
|
||||
{{range $s := .AvatarStackScenarios}}
|
||||
<tr>
|
||||
<td>{{$s.Label}}</td>
|
||||
<td>{{ctx.RenderUtils.AvatarStackWithNames $s.Data}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "devtest/devtest-footer"}}
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1>Commit Sign Badges</h1>
|
||||
{{range $commit := .MockCommits}}
|
||||
<div class="flex-text-block tw-my-2">
|
||||
{{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}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="blame-info">
|
||||
<div class="blame-data">
|
||||
<div class="blame-avatar">
|
||||
{{$row.Avatar}}
|
||||
{{if $row.AvatarStackData}}{{ctx.RenderUtils.AvatarStack $row.AvatarStackData}}{{end}}
|
||||
</div>
|
||||
<div class="blame-message muted-links" title="{{$row.CommitMessage}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $row.CommitMessage $row.CommitURL $.Repository}}
|
||||
|
||||
@@ -154,6 +154,29 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .CommitOtherParticipants}}
|
||||
<div class="flex-text-inline">
|
||||
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.coauthored_by"}}</span>
|
||||
{{range $participant := .CommitOtherParticipants}}
|
||||
{{$user := $participant.GiteaUser}}
|
||||
{{$gitIdentity := $participant.GitIdentity}}
|
||||
{{if $user}}
|
||||
{{ctx.AvatarUtils.Avatar $user 20}}
|
||||
<a class="muted" href="{{$user.HomeLink}}"><strong>{{$user.GetDisplayName}}</strong></a>
|
||||
{{else}}
|
||||
{{$gitName := $gitIdentity.Name}}
|
||||
{{$gitEmail := $gitIdentity.Email}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}}
|
||||
{{if $gitEmail}}
|
||||
<a class="muted" href="mailto:{{$gitEmail}}"><strong>{{$gitName}}</strong></a>
|
||||
{{else}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}}
|
||||
{{end}}
|
||||
|
||||
@@ -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 -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock"}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.Avatar $signingUser 16}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.Avatar $signingUser 20}}</span>
|
||||
{{- else -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock-cog"}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 16}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 20}}</span>
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-unlock"}}</span>
|
||||
|
||||
@@ -2,27 +2,21 @@
|
||||
<table class="ui very basic table unstackable" id="commits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
|
||||
<th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
|
||||
<th class="one wide"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="commit-list">
|
||||
{{$commitRepoLink := $.RepoLink}}{{if $.CommitRepoLink}}{{$commitRepoLink = $.CommitRepoLink}}{{end}}
|
||||
{{range $commit := .Commits}}
|
||||
{{range $commit := $.Commits}}
|
||||
{{$gitCommit := $commit.GitCommit}}
|
||||
{{$commitID := $gitCommit.ID.String}}
|
||||
<tr>
|
||||
<td class="author">
|
||||
<span class="author-wrapper">
|
||||
{{- if .User -}}
|
||||
{{- ctx.AvatarUtils.Avatar .User 20 "tw-mr-2" -}}
|
||||
{{- .User.GetShortDisplayNameLinkHTML -}}
|
||||
{{- else -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20 "tw-mr-2" -}}
|
||||
{{- .Author.Name -}}
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{ctx.RenderUtils.AvatarStackWithNames $commit.AvatarStackData}}
|
||||
</td>
|
||||
<td class="sha">
|
||||
{{$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}}
|
||||
</td>
|
||||
<td class="message">
|
||||
<span class="message-wrapper">
|
||||
{{if $.PageIsWiki}}
|
||||
<span class="commit-summary {{if gt $commit.ParentCount 1}} grey text{{end}}" title="{{$commit.MessageTitle}}">
|
||||
{{$commit.MessageTitle | ctx.RenderUtils.RenderEmoji}}
|
||||
<span class="commit-summary {{if gt $gitCommit.ParentCount 1}} grey text{{end}}" title="{{$gitCommit.MessageTitle}}">
|
||||
{{$gitCommit.MessageTitle | ctx.RenderUtils.RenderEmoji}}
|
||||
</span>
|
||||
{{else}}
|
||||
{{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commit.ID.String)}}
|
||||
<span class="commit-summary {{if gt $commit.ParentCount 1}} grey text{{end}}" title="{{$commit.MessageTitle}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.Repository}}
|
||||
{{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commitID)}}
|
||||
<span class="commit-summary {{if gt $gitCommit.ParentCount 1}} grey text{{end}}" title="{{$gitCommit.MessageTitle}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.Repository}}
|
||||
</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<button class="ui button ellipsis-button" aria-expanded="false" data-global-click="onRepoEllipsisButtonClick">...</button>
|
||||
{{end}}
|
||||
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
|
||||
{{if $commit.MessageBody}}
|
||||
<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.Repository}}</pre>
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.Repository}}</pre>
|
||||
{{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}}
|
||||
</td>
|
||||
{{if .Committer}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince .Committer.When}}</td>
|
||||
{{else}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince .Author.When}}</td>
|
||||
{{end}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince $gitCommit.Committer.When}}</td>
|
||||
<td class="tw-text-right tw-py-0">
|
||||
<button class="btn interact-bg tw-p-2 copy-commit-id" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{.ID}}">{{svg "octicon-copy"}}</button>
|
||||
<button class="btn interact-bg tw-p-2 copy-commit-id" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{$commitID}}">{{svg "octicon-copy"}}</button>
|
||||
{{/* at the moment, wiki doesn't support these "view" links like "view at history point" */}}
|
||||
{{if not $.PageIsWiki}}
|
||||
{{/* view single file diff */}}
|
||||
{{if $.FileTreePath}}
|
||||
<a class="btn interact-bg tw-p-2 view-single-diff" data-tooltip-content="{{ctx.Locale.Tr "repo.commits.view_file_diff"}}"
|
||||
href="{{$commitRepoLink}}/commit/{{.ID.String}}?files={{$.FileTreePath}}"
|
||||
href="{{$commitRepoLink}}/commit/{{$commitID}}?files={{$.FileTreePath}}"
|
||||
>{{svg "octicon-file-diff"}}</a>
|
||||
{{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}}
|
||||
<a class="btn interact-bg tw-p-2 view-commit-path" data-tooltip-content="{{ctx.Locale.Tr "repo.commits.view_path"}}" href="{{$viewCommitLink}}">{{svg "octicon-file-code"}}</a>
|
||||
{{end}}
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
{{$index := 0}}
|
||||
<div class="timeline-item commits-list">
|
||||
{{range $commit := .comment.Commits}}
|
||||
{{range $commit := $.comment.Commits}}
|
||||
{{$gitCommit := $commit.GitCommit}}
|
||||
{{$tag := printf "%s-%d" $.comment.HashTag $index}}
|
||||
{{$index = Eval $index "+" 1}}
|
||||
<div class="flex-text-block" id="{{$tag}}">{{/*singular-commit*/}}
|
||||
<span class="badge badge-commit">{{svg "octicon-git-commit"}}</span>
|
||||
{{if .User}}
|
||||
<a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}}</a>
|
||||
{{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)}}
|
||||
|
||||
<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{$commit.MessageTitle}}">
|
||||
{{- ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{$gitCommit.MessageTitle}}">
|
||||
{{- ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
</span>
|
||||
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<button class="ui button ellipsis-button show-panel toggle" data-panel="[data-singular-commit-body-for='{{$tag}}']">...</button>
|
||||
{{end}}
|
||||
|
||||
<span class="flex-text-block">
|
||||
{{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}}
|
||||
</span>
|
||||
</div>
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<pre class="commit-body tw-ml-[33px] tw-hidden" data-singular-commit-body-for="{{$tag}}">
|
||||
{{- ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
{{- ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
</pre>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -41,14 +41,7 @@
|
||||
</span>
|
||||
|
||||
<span class="flex-text-inline tw-text-12">
|
||||
{{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}}
|
||||
</span>
|
||||
|
||||
<span class="time flex-text-inline">{{DateUtils.FullTime $commit.Date}}</span>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
{{if not .LatestCommit}}
|
||||
…
|
||||
{{else}}
|
||||
<span class="author-wrapper">
|
||||
{{- if .LatestCommitUser -}}
|
||||
{{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommitUser.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{- else if .LatestCommit.Author -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommit.Author.Name}}</strong>
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{ctx.RenderUtils.AvatarStackWithNames .LatestCommitAvatarStackData}}
|
||||
|
||||
{{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}}
|
||||
|
||||
|
||||
@@ -89,10 +89,10 @@
|
||||
{{$repo := .Repo}}
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
{{range $pushCommit := $push.Commits}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink $pushCommit.Sha1}}
|
||||
<div class="flex-text-block">
|
||||
<img loading="lazy" alt class="ui avatar" src="{{$push.AvatarLink ctx .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
|
||||
<a class="ui sha label" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
|
||||
{{ctx.RenderUtils.AvatarStackPushCommit $pushCommit}}
|
||||
<a class="ui sha label" href="{{$commitLink}}">{{ShortSha $pushCommit.Sha1}}</a>
|
||||
<span class="tw-inline-block tw-truncate">
|
||||
{{ctx.RenderUtils.RenderCommitMessage $pushCommit.Message $repo}}
|
||||
</span>
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
@import "./font_i18n.css";
|
||||
@import "./base.css";
|
||||
@import "./avatar.css";
|
||||
@import "./home.css";
|
||||
@import "./install.css";
|
||||
|
||||
|
||||
+1
-11
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user