2024-02-15 17:21:13 -05:00
|
|
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
package repository
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
2024-05-09 14:32:18 -04:00
|
|
|
"net/mail"
|
2024-02-15 17:21:13 -05:00
|
|
|
"os"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"code.gitea.io/gitea/models/avatars"
|
|
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
|
|
user_model "code.gitea.io/gitea/models/user"
|
2024-04-13 04:38:44 -04:00
|
|
|
"code.gitea.io/gitea/modules/cache"
|
2024-02-15 17:21:13 -05:00
|
|
|
"code.gitea.io/gitea/modules/git"
|
|
|
|
"code.gitea.io/gitea/modules/gitrepo"
|
|
|
|
"code.gitea.io/gitea/modules/graceful"
|
|
|
|
"code.gitea.io/gitea/modules/log"
|
|
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
contributorStatsCacheKey = "GetContributorStats/%s/%s"
|
|
|
|
contributorStatsCacheTimeout int64 = 60 * 10
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
ErrAwaitGeneration = errors.New("generation took longer than ")
|
|
|
|
awaitGenerationTime = time.Second * 5
|
|
|
|
generateLock = sync.Map{}
|
|
|
|
)
|
|
|
|
|
|
|
|
type WeekData struct {
|
|
|
|
Week int64 `json:"week"` // Starting day of the week as Unix timestamp
|
|
|
|
Additions int `json:"additions"` // Number of additions in that week
|
|
|
|
Deletions int `json:"deletions"` // Number of deletions in that week
|
|
|
|
Commits int `json:"commits"` // Number of commits in that week
|
|
|
|
}
|
|
|
|
|
|
|
|
// ContributorData represents statistical git commit count data
|
|
|
|
type ContributorData struct {
|
|
|
|
Name string `json:"name"` // Display name of the contributor
|
|
|
|
Login string `json:"login"` // Login name of the contributor in case it exists
|
|
|
|
AvatarLink string `json:"avatar_link"`
|
|
|
|
HomeLink string `json:"home_link"`
|
|
|
|
TotalCommits int64 `json:"total_commits"`
|
|
|
|
Weeks map[int64]*WeekData `json:"weeks"`
|
|
|
|
}
|
|
|
|
|
2024-05-09 14:32:18 -04:00
|
|
|
// ExtendedCommitStats contains information for commit stats with both author and coauthors data
|
2024-02-15 17:21:13 -05:00
|
|
|
type ExtendedCommitStats struct {
|
2024-05-09 14:32:18 -04:00
|
|
|
Author *api.CommitUser `json:"author"`
|
|
|
|
CoAuthors []*api.CommitUser `json:"co_authors"`
|
|
|
|
Stats *api.CommitStats `json:"stats"`
|
2024-02-15 17:21:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
const layout = time.DateOnly
|
|
|
|
|
|
|
|
func findLastSundayBeforeDate(dateStr string) (string, error) {
|
|
|
|
date, err := time.Parse(layout, dateStr)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
weekday := date.Weekday()
|
|
|
|
daysToSubtract := int(weekday) - int(time.Sunday)
|
|
|
|
if daysToSubtract < 0 {
|
|
|
|
daysToSubtract += 7
|
|
|
|
}
|
|
|
|
|
|
|
|
lastSunday := date.AddDate(0, 0, -daysToSubtract)
|
|
|
|
return lastSunday.Format(layout), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetContributorStats returns contributors stats for git commits for given revision or default branch
|
2024-04-13 04:38:44 -04:00
|
|
|
func GetContributorStats(ctx context.Context, cache cache.StringCache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
|
2024-02-15 17:21:13 -05:00
|
|
|
// as GetContributorStats is resource intensive we cache the result
|
|
|
|
cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
|
|
|
|
if !cache.IsExist(cacheKey) {
|
|
|
|
genReady := make(chan struct{})
|
|
|
|
|
2024-04-13 04:38:44 -04:00
|
|
|
// dont start multiple async generations
|
2024-02-15 17:21:13 -05:00
|
|
|
_, run := generateLock.Load(cacheKey)
|
|
|
|
if run {
|
|
|
|
return nil, ErrAwaitGeneration
|
|
|
|
}
|
|
|
|
|
|
|
|
generateLock.Store(cacheKey, struct{}{})
|
|
|
|
// run generation async
|
|
|
|
go generateContributorStats(genReady, cache, cacheKey, repo, revision)
|
|
|
|
|
|
|
|
select {
|
|
|
|
case <-time.After(awaitGenerationTime):
|
|
|
|
return nil, ErrAwaitGeneration
|
|
|
|
case <-genReady:
|
|
|
|
// we got generation ready before timeout
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout)
|
2024-04-13 04:38:44 -04:00
|
|
|
var res map[string]*ContributorData
|
|
|
|
if _, cacheErr := cache.GetJSON(cacheKey, &res); cacheErr != nil {
|
|
|
|
return nil, fmt.Errorf("cached error: %w", cacheErr.ToError())
|
2024-02-15 17:21:13 -05:00
|
|
|
}
|
2024-04-13 04:38:44 -04:00
|
|
|
return res, nil
|
2024-02-15 17:21:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
|
|
|
|
func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
|
|
|
|
baseCommit, err := repo.GetCommit(revision)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stdoutReader, stdoutWriter, err := os.Pipe()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
defer func() {
|
|
|
|
_ = stdoutReader.Close()
|
|
|
|
_ = stdoutWriter.Close()
|
|
|
|
}()
|
|
|
|
|
2024-05-09 14:32:18 -04:00
|
|
|
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")
|
2024-02-15 17:21:13 -05:00
|
|
|
gitCmd.AddDynamicArguments(baseCommit.ID.String())
|
|
|
|
|
|
|
|
var extendedCommitStats []*ExtendedCommitStats
|
|
|
|
stderr := new(strings.Builder)
|
|
|
|
err = gitCmd.Run(&git.RunOpts{
|
|
|
|
Dir: repo.Path,
|
|
|
|
Stdout: stdoutWriter,
|
|
|
|
Stderr: stderr,
|
|
|
|
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
|
|
|
|
_ = stdoutWriter.Close()
|
|
|
|
scanner := bufio.NewScanner(stdoutReader)
|
|
|
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
if line != "---" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
scanner.Scan()
|
|
|
|
authorName := strings.TrimSpace(scanner.Text())
|
|
|
|
scanner.Scan()
|
|
|
|
authorEmail := strings.TrimSpace(scanner.Text())
|
|
|
|
scanner.Scan()
|
|
|
|
date := strings.TrimSpace(scanner.Text())
|
2024-05-09 14:32:18 -04:00
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2024-02-15 17:21:13 -05:00
|
|
|
scanner.Scan()
|
|
|
|
stats := strings.TrimSpace(scanner.Text())
|
|
|
|
if authorName == "" || authorEmail == "" || date == "" || stats == "" {
|
|
|
|
// FIXME: find a better way to parse the output so that we will handle this properly
|
|
|
|
log.Warn("Something is wrong with git log output, skipping...")
|
|
|
|
log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
// 1 file changed, 1 insertion(+), 1 deletion(-)
|
|
|
|
fields := strings.Split(stats, ",")
|
|
|
|
|
|
|
|
commitStats := api.CommitStats{}
|
|
|
|
for _, field := range fields[1:] {
|
|
|
|
parts := strings.Split(strings.TrimSpace(field), " ")
|
|
|
|
value, contributionType := parts[0], parts[1]
|
|
|
|
amount, _ := strconv.Atoi(value)
|
|
|
|
|
|
|
|
if strings.HasPrefix(contributionType, "insertion") {
|
|
|
|
commitStats.Additions = amount
|
|
|
|
} else {
|
|
|
|
commitStats.Deletions = amount
|
|
|
|
}
|
|
|
|
}
|
|
|
|
commitStats.Total = commitStats.Additions + commitStats.Deletions
|
|
|
|
scanner.Text() // empty line at the end
|
|
|
|
|
|
|
|
res := &ExtendedCommitStats{
|
|
|
|
Author: &api.CommitUser{
|
|
|
|
Identity: api.Identity{
|
|
|
|
Name: authorName,
|
|
|
|
Email: authorEmail,
|
|
|
|
},
|
|
|
|
Date: date,
|
|
|
|
},
|
2024-05-09 14:32:18 -04:00
|
|
|
CoAuthors: coAuthors,
|
|
|
|
Stats: &commitStats,
|
2024-02-15 17:21:13 -05:00
|
|
|
}
|
|
|
|
extendedCommitStats = append(extendedCommitStats, res)
|
|
|
|
}
|
|
|
|
_ = stdoutReader.Close()
|
|
|
|
return nil
|
|
|
|
},
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr)
|
|
|
|
}
|
|
|
|
|
|
|
|
return extendedCommitStats, nil
|
|
|
|
}
|
|
|
|
|
2024-05-09 14:32:18 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-04-13 04:38:44 -04:00
|
|
|
func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) {
|
2024-02-15 17:21:13 -05:00
|
|
|
ctx := graceful.GetManager().HammerContext()
|
|
|
|
|
|
|
|
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
|
|
|
if err != nil {
|
2024-04-13 04:38:44 -04:00
|
|
|
_ = cache.PutJSON(cacheKey, fmt.Errorf("OpenRepository: %w", err), contributorStatsCacheTimeout)
|
2024-02-15 17:21:13 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
defer closer.Close()
|
|
|
|
|
|
|
|
if len(revision) == 0 {
|
|
|
|
revision = repo.DefaultBranch
|
|
|
|
}
|
|
|
|
extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
|
|
|
|
if err != nil {
|
2024-04-13 04:38:44 -04:00
|
|
|
_ = cache.PutJSON(cacheKey, fmt.Errorf("ExtendedCommitStats: %w", err), contributorStatsCacheTimeout)
|
2024-02-15 17:21:13 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
if len(extendedCommitStats) == 0 {
|
2024-04-13 04:38:44 -04:00
|
|
|
_ = cache.PutJSON(cacheKey, fmt.Errorf("no commit stats returned for revision '%s'", revision), contributorStatsCacheTimeout)
|
2024-02-15 17:21:13 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
|
|
|
|
contributorsCommitStats := make(map[string]*ContributorData)
|
|
|
|
contributorsCommitStats["total"] = &ContributorData{
|
|
|
|
Name: "Total",
|
|
|
|
Weeks: make(map[int64]*WeekData),
|
|
|
|
}
|
|
|
|
total := contributorsCommitStats["total"]
|
|
|
|
|
|
|
|
for _, v := range extendedCommitStats {
|
|
|
|
userEmail := v.Author.Email
|
|
|
|
if len(userEmail) == 0 {
|
|
|
|
continue
|
|
|
|
}
|
2024-05-09 14:32:18 -04:00
|
|
|
|
|
|
|
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)
|
2024-02-15 17:21:13 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-13 04:38:44 -04:00
|
|
|
_ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
|
2024-02-15 17:21:13 -05:00
|
|
|
generateLock.Delete(cacheKey)
|
|
|
|
if genDone != nil {
|
|
|
|
genDone <- struct{}{}
|
|
|
|
}
|
|
|
|
}
|
2024-05-09 14:32:18 -04:00
|
|
|
|
|
|
|
func getContributorData(ctx context.Context, contributorsCommitStats map[string]*ContributorData, user *api.CommitUser, defaultUserAvatarLink string) *ContributorData {
|
|
|
|
userEmail := user.Email
|
|
|
|
u, _ := user_model.GetUserByEmail(ctx, userEmail)
|
|
|
|
if u != nil {
|
|
|
|
// update userEmail with user's primary email address so
|
|
|
|
// that different mail addresses will linked to same account
|
|
|
|
userEmail = u.GetEmail()
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, ok := contributorsCommitStats[userEmail]; !ok {
|
|
|
|
if u == nil {
|
|
|
|
avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
|
|
|
|
if avatarLink == "" {
|
|
|
|
avatarLink = defaultUserAvatarLink
|
|
|
|
}
|
|
|
|
contributorsCommitStats[userEmail] = &ContributorData{
|
|
|
|
Name: user.Name,
|
|
|
|
AvatarLink: avatarLink,
|
|
|
|
Weeks: make(map[int64]*WeekData),
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
contributorsCommitStats[userEmail] = &ContributorData{
|
|
|
|
Name: u.DisplayName(),
|
|
|
|
Login: u.LowerName,
|
|
|
|
AvatarLink: u.AvatarLinkWithSize(ctx, 0),
|
|
|
|
HomeLink: u.HomeLink(),
|
|
|
|
Weeks: make(map[int64]*WeekData),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return contributorsCommitStats[userEmail]
|
|
|
|
}
|
|
|
|
|
|
|
|
func updateUserAndOverallStats(stats *api.CommitStats, commitDate string, user, total *ContributorData, isCoAuthor bool) {
|
|
|
|
startingOfWeek, _ := findLastSundayBeforeDate(commitDate)
|
|
|
|
|
|
|
|
val, _ := time.Parse(layout, startingOfWeek)
|
|
|
|
week := val.UnixMilli()
|
|
|
|
|
|
|
|
if user.Weeks[week] == nil {
|
|
|
|
user.Weeks[week] = &WeekData{
|
|
|
|
Additions: 0,
|
|
|
|
Deletions: 0,
|
|
|
|
Commits: 0,
|
|
|
|
Week: week,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if total.Weeks[week] == nil {
|
|
|
|
total.Weeks[week] = &WeekData{
|
|
|
|
Additions: 0,
|
|
|
|
Deletions: 0,
|
|
|
|
Commits: 0,
|
|
|
|
Week: week,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Update user statistics
|
|
|
|
user.Weeks[week].Additions += stats.Additions
|
|
|
|
user.Weeks[week].Deletions += stats.Deletions
|
|
|
|
user.Weeks[week].Commits++
|
|
|
|
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
|
|
|
|
total.Weeks[week].Additions += stats.Additions
|
|
|
|
total.Weeks[week].Deletions += stats.Deletions
|
|
|
|
total.Weeks[week].Commits++
|
|
|
|
total.TotalCommits++
|
|
|
|
}
|