mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-13 15:14:00 +00:00
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>
310 lines
11 KiB
Go
310 lines
11 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package devtest
|
||
|
||
import (
|
||
"fmt"
|
||
"html/template"
|
||
"net/http"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"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"
|
||
"gitea.dev/modules/git"
|
||
"gitea.dev/modules/indexer/code"
|
||
"gitea.dev/modules/templates"
|
||
"gitea.dev/modules/util"
|
||
"gitea.dev/services/context"
|
||
)
|
||
|
||
// List all devtest templates, they will be used for e2e tests for the UI components
|
||
func List(ctx *context.Context) {
|
||
templateNames, err := templates.AssetFS().ListFiles("devtest", true)
|
||
if err != nil {
|
||
ctx.ServerError("AssetFS().ListFiles", err)
|
||
return
|
||
}
|
||
var subNames []string
|
||
for _, tmplName := range templateNames {
|
||
subName := strings.TrimSuffix(tmplName, ".tmpl")
|
||
if !strings.HasPrefix(subName, "devtest-") {
|
||
subNames = append(subNames, subName)
|
||
}
|
||
}
|
||
ctx.Data["SubNames"] = subNames
|
||
ctx.HTML(http.StatusOK, "devtest/devtest-list")
|
||
}
|
||
|
||
func FetchActionTest(ctx *context.Context) {
|
||
_ = ctx.Req.ParseForm()
|
||
ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
|
||
"Form: " + ctx.Req.Form.Encode() + "\n" +
|
||
"PostForm: " + ctx.Req.PostForm.Encode(),
|
||
)
|
||
time.Sleep(2 * time.Second)
|
||
ctx.JSONRedirect("")
|
||
}
|
||
|
||
func prepareMockDataGiteaUI(_ *context.Context) {}
|
||
|
||
func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||
var commits []*asymkey.SignCommit
|
||
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
|
||
mockUser := mockUsers[0]
|
||
commits = append(commits, &asymkey.SignCommit{
|
||
Verification: &asymkey.CommitVerification{},
|
||
UserCommit: &gituser.UserCommit{
|
||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||
},
|
||
})
|
||
commits = append(commits, &asymkey.SignCommit{
|
||
Verification: &asymkey.CommitVerification{
|
||
Verified: true,
|
||
Reason: "name / key-id",
|
||
SigningUser: mockUser,
|
||
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
|
||
TrustStatus: "trusted",
|
||
},
|
||
UserCommit: &gituser.UserCommit{
|
||
AuthorUser: mockUser,
|
||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||
},
|
||
})
|
||
commits = append(commits, &asymkey.SignCommit{
|
||
Verification: &asymkey.CommitVerification{
|
||
Verified: true,
|
||
Reason: "name / key-id",
|
||
SigningUser: mockUser,
|
||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||
TrustStatus: "untrusted",
|
||
},
|
||
UserCommit: &gituser.UserCommit{
|
||
AuthorUser: mockUser,
|
||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||
},
|
||
})
|
||
commits = append(commits, &asymkey.SignCommit{
|
||
Verification: &asymkey.CommitVerification{
|
||
Verified: true,
|
||
Reason: "name / key-id",
|
||
SigningUser: mockUser,
|
||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||
TrustStatus: "other(unmatch)",
|
||
},
|
||
UserCommit: &gituser.UserCommit{
|
||
AuthorUser: mockUser,
|
||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||
},
|
||
})
|
||
commits = append(commits, &asymkey.SignCommit{
|
||
Verification: &asymkey.CommitVerification{
|
||
Warning: true,
|
||
Reason: "gpg.error",
|
||
SigningEmail: "test@example.com",
|
||
},
|
||
UserCommit: &gituser.UserCommit{
|
||
AuthorUser: mockUser,
|
||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||
},
|
||
})
|
||
|
||
ctx.Data["MockCommits"] = commits
|
||
}
|
||
|
||
func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
|
||
fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",")
|
||
selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0])
|
||
selectedStyle := ctx.FormString("style", badge.DefaultStyle)
|
||
var badges []badge.Badge
|
||
badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green"))
|
||
for r := range rune(256) {
|
||
if unicode.IsPrint(r) {
|
||
s := strings.Repeat(string(r), 15)
|
||
badges = append(badges, badge.GenerateBadge(s, util.TruncateRunes(s, 7), "green"))
|
||
}
|
||
}
|
||
|
||
var badgeSVGs []template.HTML
|
||
for i, b := range badges {
|
||
b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
|
||
b.FontFamily = selectedFontFamilyName
|
||
var h template.HTML
|
||
var err error
|
||
switch selectedStyle {
|
||
case badge.StyleFlat:
|
||
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat", map[string]any{"Badge": b})
|
||
case badge.StyleFlatSquare:
|
||
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat-square", map[string]any{"Badge": b})
|
||
default:
|
||
err = fmt.Errorf("unknown badge style: %s", selectedStyle)
|
||
}
|
||
if err != nil {
|
||
ctx.ServerError("RenderToHTML", err)
|
||
return
|
||
}
|
||
badgeSVGs = append(badgeSVGs, h)
|
||
}
|
||
ctx.Data["BadgeSVGs"] = badgeSVGs
|
||
ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
|
||
ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
|
||
ctx.Data["BadgeStyles"] = badge.GlobalVars().AllStyles
|
||
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
|
||
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
|
||
ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second)
|
||
ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute)
|
||
ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute)
|
||
ctx.Data["TimePast3m"] = now.Add(-3 * time.Minute)
|
||
ctx.Data["TimePast1h"] = now.Add(-1 * time.Hour)
|
||
ctx.Data["TimePast3h"] = now.Add(-3 * time.Hour)
|
||
ctx.Data["TimePast1d"] = now.Add(-24 * time.Hour)
|
||
ctx.Data["TimePast2d"] = now.Add(-2 * 24 * time.Hour)
|
||
ctx.Data["TimePast3d"] = now.Add(-3 * 24 * time.Hour)
|
||
ctx.Data["TimePast26h"] = now.Add(-26 * time.Hour)
|
||
ctx.Data["TimePast40d"] = now.Add(-40 * 24 * time.Hour)
|
||
ctx.Data["TimePast60d"] = now.Add(-60 * 24 * time.Hour)
|
||
ctx.Data["TimePast1y"] = now.Add(-366 * 24 * time.Hour)
|
||
ctx.Data["TimeFuture1h"] = now.Add(1 * time.Hour)
|
||
ctx.Data["TimeFuture3h"] = now.Add(3 * time.Hour)
|
||
ctx.Data["TimeFuture3d"] = now.Add(3 * 24 * time.Hour)
|
||
ctx.Data["TimeFuture1y"] = now.Add(366 * 24 * time.Hour)
|
||
}
|
||
|
||
func prepareMockData(ctx *context.Context) {
|
||
switch ctx.Req.URL.Path {
|
||
case "/devtest/gitea-ui":
|
||
prepareMockDataGiteaUI(ctx)
|
||
case "/devtest/badge-commit-sign":
|
||
prepareMockDataBadgeCommitSign(ctx)
|
||
case "/devtest/badge-actions-svg":
|
||
prepareMockDataBadgeActionsSvg(ctx)
|
||
case "/devtest/relative-time":
|
||
prepareMockDataRelativeTime(ctx)
|
||
case "/devtest/toast-and-message":
|
||
prepareMockDataToastAndMessage(ctx)
|
||
case "/devtest/unicode-escape":
|
||
prepareMockDataUnicodeEscape(ctx)
|
||
case "/devtest/avatar-stack":
|
||
prepareMockDataAvatarStack(ctx)
|
||
}
|
||
}
|
||
|
||
func prepareMockDataToastAndMessage(ctx *context.Context) {
|
||
msgWithDetails, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
|
||
"Message": "message with details <script>escape xss</script>",
|
||
"Summary": "summary with details",
|
||
"Details": "details line 1\n details line 2\n details line 3",
|
||
})
|
||
msgWithSummary, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
|
||
"Message": "message with summary <script>escape xss</script>",
|
||
"Summary": "summary only",
|
||
})
|
||
|
||
ctx.Flash.ErrorMsg = string(msgWithDetails)
|
||
ctx.Flash.WarningMsg = string(msgWithSummary)
|
||
ctx.Flash.InfoMsg = "a long message with line break\nthe second line <script>removed xss</script>"
|
||
ctx.Flash.SuccessMsg = "single line message <script>removed xss</script>"
|
||
ctx.Data["Flash"] = ctx.Flash
|
||
}
|
||
|
||
func prepareMockDataUnicodeEscape(ctx *context.Context) {
|
||
content := "// demo code\n"
|
||
content += "if accessLevel != \"user\u202E \u2066// Check if admin (invisible char)\u2069 \u2066\" { }\n"
|
||
content += "if O𝐾 { } // ambiguous char\n"
|
||
content += "if O𝐾 && accessLevel != \"user\u202E \u2066// ambiguous char + invisible char\u2069 \u2066\" { }\n"
|
||
content += "str := `\xef` // broken char\n"
|
||
content += "str := `\x00 \x19 \x7f` // control char\n"
|
||
|
||
lineNums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
|
||
|
||
highlightLines := code.HighlightSearchResultCode("demo.go", "", lineNums, content)
|
||
escapeStatus := &charset.EscapeStatus{}
|
||
lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines))
|
||
for i, hl := range highlightLines {
|
||
lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, ctx.Locale)
|
||
escapeStatus = escapeStatus.Or(lineEscapeStatus[i])
|
||
}
|
||
ctx.Data["HighlightLines"] = highlightLines
|
||
ctx.Data["EscapeStatus"] = escapeStatus
|
||
ctx.Data["LineEscapeStatus"] = lineEscapeStatus
|
||
}
|
||
|
||
func TmplCommon(ctx *context.Context) {
|
||
prepareMockData(ctx)
|
||
if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
|
||
ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
|
||
"Form: "+ctx.Req.Form.Encode()+"\n"+
|
||
"PostForm: "+ctx.Req.PostForm.Encode(),
|
||
true,
|
||
)
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
ctx.HTML(http.StatusOK, templates.TplName("devtest"+path.Clean("/"+ctx.PathParam("sub"))))
|
||
}
|