1
0
mirror of https://github.com/go-gitea/gitea.git synced 2024-12-04 14:46:57 -05:00

Respect Co-authored-by trailers in the contributors graph

This commit is contained in:
Kemal Zebari 2024-05-09 11:32:18 -07:00
parent d9b37d085a
commit ea4099918d
6 changed files with 285 additions and 122 deletions

View File

@ -8,6 +8,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/mail"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -53,9 +54,10 @@ type ContributorData struct {
Weeks map[int64]*WeekData `json:"weeks"` Weeks map[int64]*WeekData `json:"weeks"`
} }
// ExtendedCommitStats contains information for commit stats with author data // ExtendedCommitStats contains information for commit stats with both author and coauthors data
type ExtendedCommitStats struct { type ExtendedCommitStats struct {
Author *api.CommitUser `json:"author"` Author *api.CommitUser `json:"author"`
CoAuthors []*api.CommitUser `json:"co_authors"`
Stats *api.CommitStats `json:"stats"` Stats *api.CommitStats `json:"stats"`
} }
@ -125,8 +127,7 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
_ = stdoutWriter.Close() _ = stdoutWriter.Close()
}() }()
gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse") gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as%n%(trailers:key=Co-authored-by,valueonly=true)", "--reverse")
// AddOptionFormat("--max-count=%d", limit)
gitCmd.AddDynamicArguments(baseCommit.ID.String()) gitCmd.AddDynamicArguments(baseCommit.ID.String())
var extendedCommitStats []*ExtendedCommitStats var extendedCommitStats []*ExtendedCommitStats
@ -150,6 +151,25 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
authorEmail := strings.TrimSpace(scanner.Text()) authorEmail := strings.TrimSpace(scanner.Text())
scanner.Scan() scanner.Scan()
date := strings.TrimSpace(scanner.Text()) date := strings.TrimSpace(scanner.Text())
var coAuthors []*api.CommitUser
for scanner.Scan() {
line := scanner.Text()
if line == "" {
// There should be an empty line before we read the commit stats line.
break
}
coAuthorEmail, coAuthorName, err := parseCoAuthorTrailerValue(line)
if err != nil {
continue
}
coAuthor := &api.CommitUser{
Identity: api.Identity{Name: coAuthorName, Email: coAuthorEmail},
Date: date,
}
coAuthors = append(coAuthors, coAuthor)
}
scanner.Scan() scanner.Scan()
stats := strings.TrimSpace(scanner.Text()) stats := strings.TrimSpace(scanner.Text())
if authorName == "" || authorEmail == "" || date == "" || stats == "" { if authorName == "" || authorEmail == "" || date == "" || stats == "" {
@ -184,6 +204,7 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
}, },
Date: date, Date: date,
}, },
CoAuthors: coAuthors,
Stats: &commitStats, Stats: &commitStats,
} }
extendedCommitStats = append(extendedCommitStats, res) extendedCommitStats = append(extendedCommitStats, res)
@ -199,6 +220,31 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
return extendedCommitStats, nil return extendedCommitStats, nil
} }
var errSyntax error = errors.New("syntax error occurred")
func parseCoAuthorTrailerValue(value string) (email, name string, err error) {
value = strings.TrimSpace(value)
if !strings.HasSuffix(value, ">") {
return "", "", errSyntax
}
if openEmailBracketIdx := strings.LastIndex(value, "<"); openEmailBracketIdx == -1 {
return "", "", errSyntax
}
parts := strings.Split(value, "<")
if len(parts) < 2 {
return "", "", errSyntax
}
email = strings.TrimRight(parts[1], ">")
if _, err := mail.ParseAddress(email); err != nil {
return "", "", err
}
name = strings.TrimSpace(parts[0])
return email, name, nil
}
func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) { func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) {
ctx := graceful.GetManager().HammerContext() ctx := graceful.GetManager().HammerContext()
@ -222,8 +268,6 @@ func generateContributorStats(genDone chan struct{}, cache cache.StringCache, ca
return return
} }
layout := time.DateOnly
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0) unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
contributorsCommitStats := make(map[string]*ContributorData) contributorsCommitStats := make(map[string]*ContributorData)
contributorsCommitStats["total"] = &ContributorData{ contributorsCommitStats["total"] = &ContributorData{
@ -237,21 +281,42 @@ func generateContributorStats(genDone chan struct{}, cache cache.StringCache, ca
if len(userEmail) == 0 { if len(userEmail) == 0 {
continue continue
} }
authorData := getContributorData(ctx, contributorsCommitStats, v.Author, unknownUserAvatarLink)
date := v.Author.Date
stats := v.Stats
updateUserAndOverallStats(stats, date, authorData, total, false)
for _, coAuthor := range v.CoAuthors {
coAuthorData := getContributorData(ctx, contributorsCommitStats, coAuthor, unknownUserAvatarLink)
updateUserAndOverallStats(stats, date, coAuthorData, total, true)
}
}
_ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
generateLock.Delete(cacheKey)
if genDone != nil {
genDone <- struct{}{}
}
}
func getContributorData(ctx context.Context, contributorsCommitStats map[string]*ContributorData, user *api.CommitUser, defaultUserAvatarLink string) *ContributorData {
userEmail := user.Email
u, _ := user_model.GetUserByEmail(ctx, userEmail) u, _ := user_model.GetUserByEmail(ctx, userEmail)
if u != nil { if u != nil {
// update userEmail with user's primary email address so // update userEmail with user's primary email address so
// that different mail addresses will linked to same account // that different mail addresses will linked to same account
userEmail = u.GetEmail() userEmail = u.GetEmail()
} }
// duplicated logic
if _, ok := contributorsCommitStats[userEmail]; !ok { if _, ok := contributorsCommitStats[userEmail]; !ok {
if u == nil { if u == nil {
avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0) avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
if avatarLink == "" { if avatarLink == "" {
avatarLink = unknownUserAvatarLink avatarLink = defaultUserAvatarLink
} }
contributorsCommitStats[userEmail] = &ContributorData{ contributorsCommitStats[userEmail] = &ContributorData{
Name: v.Author.Name, Name: user.Name,
AvatarLink: avatarLink, AvatarLink: avatarLink,
Weeks: make(map[int64]*WeekData), Weeks: make(map[int64]*WeekData),
} }
@ -265,9 +330,11 @@ func generateContributorStats(genDone chan struct{}, cache cache.StringCache, ca
} }
} }
} }
// Update user statistics return contributorsCommitStats[userEmail]
user := contributorsCommitStats[userEmail] }
startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
func updateUserAndOverallStats(stats *api.CommitStats, commitDate string, user, total *ContributorData, isCoAuthor bool) {
startingOfWeek, _ := findLastSundayBeforeDate(commitDate)
val, _ := time.Parse(layout, startingOfWeek) val, _ := time.Parse(layout, startingOfWeek)
week := val.UnixMilli() week := val.UnixMilli()
@ -288,21 +355,21 @@ func generateContributorStats(genDone chan struct{}, cache cache.StringCache, ca
Week: week, Week: week,
} }
} }
user.Weeks[week].Additions += v.Stats.Additions // Update user statistics
user.Weeks[week].Deletions += v.Stats.Deletions user.Weeks[week].Additions += stats.Additions
user.Weeks[week].Deletions += stats.Deletions
user.Weeks[week].Commits++ user.Weeks[week].Commits++
user.TotalCommits++ user.TotalCommits++
if isCoAuthor {
// We would have or will count these additions/deletions/commits already when we encounter the original
// author of the commit. Let's avoid this duplication.
return
}
// Update overall statistics // Update overall statistics
total.Weeks[week].Additions += v.Stats.Additions total.Weeks[week].Additions += stats.Additions
total.Weeks[week].Deletions += v.Stats.Deletions total.Weeks[week].Deletions += stats.Deletions
total.Weeks[week].Commits++ total.Weeks[week].Commits++
total.TotalCommits++ total.TotalCommits++
} }
_ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
generateLock.Delete(cacheKey)
if genDone != nil {
genDone <- struct{}{}
}
}

View File

@ -20,17 +20,22 @@ func TestRepository_ContributorsGraph(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(db.DefaultContext)) assert.NoError(t, repo.LoadOwner(db.DefaultContext))
t.Run("non-existent revision", func(t *testing.T) {
mockCache, err := cache.NewStringCache(setting.Cache{}) mockCache, err := cache.NewStringCache(setting.Cache{})
assert.NoError(t, err) assert.NoError(t, err)
generateContributorStats(nil, mockCache, "key", repo, "404ref") generateContributorStats(nil, mockCache, "key", repo, "404ref")
var data map[string]*ContributorData var data map[string]*ContributorData
_, getErr := mockCache.GetJSON("key", &data) _, getErr := mockCache.GetJSON("key", &data)
assert.NotNil(t, getErr) assert.NotNil(t, getErr)
assert.ErrorContains(t, getErr.ToError(), "object does not exist") assert.ErrorContains(t, getErr.ToError(), "object does not exist")
})
generateContributorStats(nil, mockCache, "key2", repo, "master") t.Run("generate contributor stats", func(t *testing.T) {
exist, _ := mockCache.GetJSON("key2", &data) mockCache, err := cache.NewStringCache(setting.Cache{})
assert.NoError(t, err)
generateContributorStats(nil, mockCache, "key", repo, "master")
var data map[string]*ContributorData
exist, _ := mockCache.GetJSON("key", &data)
assert.True(t, exist) assert.True(t, exist)
var keys []string var keys []string
for k := range data { for k := range data {
@ -82,4 +87,91 @@ func TestRepository_ContributorsGraph(t *testing.T) {
}, },
}, },
}, data["total"]) }, data["total"])
})
t.Run("generate contributor stats with co-authored commit", func(t *testing.T) {
mockCache, err := cache.NewStringCache(setting.Cache{})
assert.NoError(t, err)
generateContributorStats(nil, mockCache, "key", repo, "branch-with-co-author")
var data map[string]*ContributorData
exist, _ := mockCache.GetJSON("key", &data)
assert.True(t, exist)
var keys []string
for k := range data {
keys = append(keys, k)
}
slices.Sort(keys)
assert.EqualValues(t, []string{
"ethantkoenig@gmail.com",
"fizzbuzz@example.com",
"foobar@example.com",
"jimmy.praet@telenet.be",
"jon@allspice.io",
"total",
}, keys)
// make sure we can see the author of the commit
assert.EqualValues(t, &ContributorData{
Name: "Foo Bar",
AvatarLink: "https://secure.gravatar.com/avatar/0d4907cea9d97688aa7a5e722d742f71?d=identicon",
TotalCommits: 1,
Weeks: map[int64]*WeekData{
1714867200000: {
Week: 1714867200000, // sunday 2024-05-05
Additions: 1,
Deletions: 1,
Commits: 1,
},
},
}, data["foobar@example.com"])
// make sure that we can also see the co-author
assert.EqualValues(t, &ContributorData{
Name: "Fizz Buzz",
AvatarLink: "https://secure.gravatar.com/avatar/474e3516254f43b2337011c4ac4de421?d=identicon",
TotalCommits: 1,
Weeks: map[int64]*WeekData{
1714867200000: {
Week: 1714867200000, // sunday 2024-05-05
Additions: 1,
Deletions: 1,
Commits: 1,
},
},
}, data["fizzbuzz@example.com"])
// let's also make sure we don't duplicate the additions/deletions/commits counts in the overall stats that week
assert.EqualValues(t, &ContributorData{
Name: "Total",
AvatarLink: "",
TotalCommits: 4,
Weeks: map[int64]*WeekData{
1714867200000: {
Week: 1714867200000, // sunday 2024-05-05
Additions: 1,
Deletions: 1,
Commits: 1,
},
1511654400000: {
Week: 1511654400000, // sunday 2017-11-26
Additions: 3,
Deletions: 0,
Commits: 1,
},
1607817600000: {
Week: 1607817600000, // sunday 2020-12-13
Additions: 10,
Deletions: 0,
Commits: 1,
},
1624752000000: {
Week: 1624752000000, // sunday 2021-06-27
Additions: 2,
Deletions: 0,
Commits: 1,
},
},
}, data["total"])
})
} }

View File

@ -0,0 +1 @@
xuŽÁJ1E]ç+ÞLIÒfÈPJ…"¸vãî½äERÐùzS\¹ps¹÷Â<C3B7>JÎsmLJV™A…„ÌnTí8*<2A>":oùE¶„{«G%}+V¾6Pr¯‰²‰Œ>xCdÊE$vÉïµuVJ<56>·öQ*\J<>3V˜R)„õÄ_˜×…w¡ä#(«Œ¶Æi ƒ¼Cýív<C3AD>+¼pÆÞ¸33LŸ÷µ1U<½gœ—pñºFl Ï%ó.G!žÊðëÁq ïG¸ÌÛç[<5B>)õJ½ý1?øçYa

View File

@ -0,0 +1,2 @@
x5х1 PgNЯ┴ЁЁ╝nю h( P#╞/▀и⌡·кЙ╟╜с▄C Ц╒хф°▓├. Ышb╞aтЬ°*Ц╔/T#
┌в┘·.z▐ПZJЙж|╝.W

View File

@ -0,0 +1 @@
26442ad16af268ef4768a5a30db377e860f504a3