123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- // Copyright 2023 The Gitea Authors. All rights reserved.
- // SPDX-License-Identifier: MIT
-
- package repository
-
- import (
- "bufio"
- "context"
- "errors"
- "fmt"
- "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"
- "code.gitea.io/gitea/modules/cache"
- "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"`
- }
-
- // ExtendedCommitStats contains information for commit stats with author data
- type ExtendedCommitStats struct {
- Author *api.CommitUser `json:"author"`
- Stats *api.CommitStats `json:"stats"`
- }
-
- 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
- func GetContributorStats(ctx context.Context, cache cache.StringCache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
- // as GetContributorStats is resource intensive we cache the result
- cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
- if !cache.IsExist(cacheKey) {
- genReady := make(chan struct{})
-
- // dont start multiple async generations
- _, 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)
- var res map[string]*ContributorData
- if _, cacheErr := cache.GetJSON(cacheKey, &res); cacheErr != nil {
- return nil, fmt.Errorf("cached error: %w", cacheErr.ToError())
- }
- return res, nil
- }
-
- // 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()
- }()
-
- gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
- // AddOptionFormat("--max-count=%d", limit)
- 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())
- 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,
- },
- Stats: &commitStats,
- }
- 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
- }
-
- func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) {
- ctx := graceful.GetManager().HammerContext()
-
- gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
- if err != nil {
- _ = cache.PutJSON(cacheKey, fmt.Errorf("OpenRepository: %w", err), contributorStatsCacheTimeout)
- return
- }
- defer closer.Close()
-
- if len(revision) == 0 {
- revision = repo.DefaultBranch
- }
- extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
- if err != nil {
- _ = cache.PutJSON(cacheKey, fmt.Errorf("ExtendedCommitStats: %w", err), contributorStatsCacheTimeout)
- return
- }
- if len(extendedCommitStats) == 0 {
- _ = cache.PutJSON(cacheKey, fmt.Errorf("no commit stats returned for revision '%s'", revision), contributorStatsCacheTimeout)
- return
- }
-
- layout := time.DateOnly
-
- 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
- }
- 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()
- }
- // duplicated logic
- if _, ok := contributorsCommitStats[userEmail]; !ok {
- if u == nil {
- avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
- if avatarLink == "" {
- avatarLink = unknownUserAvatarLink
- }
- contributorsCommitStats[userEmail] = &ContributorData{
- Name: v.Author.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),
- }
- }
- }
- // Update user statistics
- user := contributorsCommitStats[userEmail]
- startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
-
- 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,
- }
- }
- user.Weeks[week].Additions += v.Stats.Additions
- user.Weeks[week].Deletions += v.Stats.Deletions
- user.Weeks[week].Commits++
- user.TotalCommits++
-
- // Update overall statistics
- total.Weeks[week].Additions += v.Stats.Additions
- total.Weeks[week].Deletions += v.Stats.Deletions
- total.Weeks[week].Commits++
- total.TotalCommits++
- }
-
- _ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
- generateLock.Delete(cacheKey)
- if genDone != nil {
- genDone <- struct{}{}
- }
- }
|