e9be8b25ae
Continuation of https://github.com/go-gitea/gitea/pull/25439. Fixes #847 Before: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/24571ac8-b254-43c9-b178-97340f0dc8a9"> ---- After: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/c60b2459-9d10-4d42-8d83-d5ef0f45bf94"> --- #### Overview This is the implementation of a requested feature: Contributors graph (#847) It makes Activity page a multi-tab page and adds a new tab called Contributors. Contributors tab shows the contribution graphs over time since the repository existed. It also shows per user contribution graphs for top 100 contributors. Top 100 is calculated based on the selected contribution type (commits, additions or deletions). --- #### Demo (The demo is a bit old but still a good example to show off the main features) <video src="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014" controls width="320" height="240"> <a href="https://github.com/go-gitea/gitea/assets/32161460/9f68103f-8145-4cc2-94bc-5546daae7014">Download</a> </video> #### Features: - Select contribution type (commits, additions or deletions) - See overall and per user contribution graphs for the selected contribution type - Zoom and pan on graphs to see them in detail - See top 100 contributors based on the selected contribution type and selected time range - Go directly to users' profile by clicking their name if they are registered gitea users - Cache the results so that when the same repository is visited again fetching data will be faster --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: hiifong <i@hiif.ong> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: yp05327 <576951401@qq.com> (cherry picked from commit 21331be30cb8f6c2d8b9dd99f1061623900632b9)
320 lines
9.3 KiB
Go
320 lines
9.3 KiB
Go
// 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/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"
|
|
|
|
"gitea.com/go-chi/cache"
|
|
)
|
|
|
|
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.Cache, 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 multible 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)
|
|
|
|
switch v := cache.Get(cacheKey).(type) {
|
|
case error:
|
|
return nil, v
|
|
case map[string]*ContributorData:
|
|
return v, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected type in cache detected")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
scanner.Split(bufio.ScanLines)
|
|
|
|
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.Scan()
|
|
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.Cache, cacheKey string, repo *repo_model.Repository, revision string) {
|
|
ctx := graceful.GetManager().HammerContext()
|
|
|
|
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
|
if err != nil {
|
|
err := fmt.Errorf("OpenRepository: %w", err)
|
|
_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
|
|
return
|
|
}
|
|
defer closer.Close()
|
|
|
|
if len(revision) == 0 {
|
|
revision = repo.DefaultBranch
|
|
}
|
|
extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
|
|
if err != nil {
|
|
err := fmt.Errorf("ExtendedCommitStats: %w", err)
|
|
_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
|
|
return
|
|
}
|
|
if len(extendedCommitStats) == 0 {
|
|
err := fmt.Errorf("no commit stats returned for revision '%s'", revision)
|
|
_ = cache.Put(cacheKey, err, 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.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
|
|
generateLock.Delete(cacheKey)
|
|
if genDone != nil {
|
|
genDone <- struct{}{}
|
|
}
|
|
}
|