mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-22 20:53:40 +09:00
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
Claude Opus 4.7
silverwind
wxiaoguang
Giteabot
parent
2a84831400
commit
54916f708e
+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
|
||||
|
||||
Reference in New Issue
Block a user