You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

contributors_graph.go 9.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. // Copyright 2023 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package repository
  4. import (
  5. "bufio"
  6. "context"
  7. "errors"
  8. "fmt"
  9. "os"
  10. "strconv"
  11. "strings"
  12. "sync"
  13. "time"
  14. "code.gitea.io/gitea/models/avatars"
  15. repo_model "code.gitea.io/gitea/models/repo"
  16. user_model "code.gitea.io/gitea/models/user"
  17. "code.gitea.io/gitea/modules/git"
  18. "code.gitea.io/gitea/modules/gitrepo"
  19. "code.gitea.io/gitea/modules/graceful"
  20. "code.gitea.io/gitea/modules/log"
  21. api "code.gitea.io/gitea/modules/structs"
  22. "gitea.com/go-chi/cache"
  23. )
  24. const (
  25. contributorStatsCacheKey = "GetContributorStats/%s/%s"
  26. contributorStatsCacheTimeout int64 = 60 * 10
  27. )
  28. var (
  29. ErrAwaitGeneration = errors.New("generation took longer than ")
  30. awaitGenerationTime = time.Second * 5
  31. generateLock = sync.Map{}
  32. )
  33. type WeekData struct {
  34. Week int64 `json:"week"` // Starting day of the week as Unix timestamp
  35. Additions int `json:"additions"` // Number of additions in that week
  36. Deletions int `json:"deletions"` // Number of deletions in that week
  37. Commits int `json:"commits"` // Number of commits in that week
  38. }
  39. // ContributorData represents statistical git commit count data
  40. type ContributorData struct {
  41. Name string `json:"name"` // Display name of the contributor
  42. Login string `json:"login"` // Login name of the contributor in case it exists
  43. AvatarLink string `json:"avatar_link"`
  44. HomeLink string `json:"home_link"`
  45. TotalCommits int64 `json:"total_commits"`
  46. Weeks map[int64]*WeekData `json:"weeks"`
  47. }
  48. // ExtendedCommitStats contains information for commit stats with author data
  49. type ExtendedCommitStats struct {
  50. Author *api.CommitUser `json:"author"`
  51. Stats *api.CommitStats `json:"stats"`
  52. }
  53. const layout = time.DateOnly
  54. func findLastSundayBeforeDate(dateStr string) (string, error) {
  55. date, err := time.Parse(layout, dateStr)
  56. if err != nil {
  57. return "", err
  58. }
  59. weekday := date.Weekday()
  60. daysToSubtract := int(weekday) - int(time.Sunday)
  61. if daysToSubtract < 0 {
  62. daysToSubtract += 7
  63. }
  64. lastSunday := date.AddDate(0, 0, -daysToSubtract)
  65. return lastSunday.Format(layout), nil
  66. }
  67. // GetContributorStats returns contributors stats for git commits for given revision or default branch
  68. func GetContributorStats(ctx context.Context, cache cache.Cache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
  69. // as GetContributorStats is resource intensive we cache the result
  70. cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
  71. if !cache.IsExist(cacheKey) {
  72. genReady := make(chan struct{})
  73. // dont start multible async generations
  74. _, run := generateLock.Load(cacheKey)
  75. if run {
  76. return nil, ErrAwaitGeneration
  77. }
  78. generateLock.Store(cacheKey, struct{}{})
  79. // run generation async
  80. go generateContributorStats(genReady, cache, cacheKey, repo, revision)
  81. select {
  82. case <-time.After(awaitGenerationTime):
  83. return nil, ErrAwaitGeneration
  84. case <-genReady:
  85. // we got generation ready before timeout
  86. break
  87. }
  88. }
  89. // TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout)
  90. switch v := cache.Get(cacheKey).(type) {
  91. case error:
  92. return nil, v
  93. case map[string]*ContributorData:
  94. return v, nil
  95. default:
  96. return nil, fmt.Errorf("unexpected type in cache detected")
  97. }
  98. }
  99. // getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
  100. func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
  101. baseCommit, err := repo.GetCommit(revision)
  102. if err != nil {
  103. return nil, err
  104. }
  105. stdoutReader, stdoutWriter, err := os.Pipe()
  106. if err != nil {
  107. return nil, err
  108. }
  109. defer func() {
  110. _ = stdoutReader.Close()
  111. _ = stdoutWriter.Close()
  112. }()
  113. gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
  114. // AddOptionFormat("--max-count=%d", limit)
  115. gitCmd.AddDynamicArguments(baseCommit.ID.String())
  116. var extendedCommitStats []*ExtendedCommitStats
  117. stderr := new(strings.Builder)
  118. err = gitCmd.Run(&git.RunOpts{
  119. Dir: repo.Path,
  120. Stdout: stdoutWriter,
  121. Stderr: stderr,
  122. PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
  123. _ = stdoutWriter.Close()
  124. scanner := bufio.NewScanner(stdoutReader)
  125. for scanner.Scan() {
  126. line := strings.TrimSpace(scanner.Text())
  127. if line != "---" {
  128. continue
  129. }
  130. scanner.Scan()
  131. authorName := strings.TrimSpace(scanner.Text())
  132. scanner.Scan()
  133. authorEmail := strings.TrimSpace(scanner.Text())
  134. scanner.Scan()
  135. date := strings.TrimSpace(scanner.Text())
  136. scanner.Scan()
  137. stats := strings.TrimSpace(scanner.Text())
  138. if authorName == "" || authorEmail == "" || date == "" || stats == "" {
  139. // FIXME: find a better way to parse the output so that we will handle this properly
  140. log.Warn("Something is wrong with git log output, skipping...")
  141. log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats)
  142. continue
  143. }
  144. // 1 file changed, 1 insertion(+), 1 deletion(-)
  145. fields := strings.Split(stats, ",")
  146. commitStats := api.CommitStats{}
  147. for _, field := range fields[1:] {
  148. parts := strings.Split(strings.TrimSpace(field), " ")
  149. value, contributionType := parts[0], parts[1]
  150. amount, _ := strconv.Atoi(value)
  151. if strings.HasPrefix(contributionType, "insertion") {
  152. commitStats.Additions = amount
  153. } else {
  154. commitStats.Deletions = amount
  155. }
  156. }
  157. commitStats.Total = commitStats.Additions + commitStats.Deletions
  158. scanner.Text() // empty line at the end
  159. res := &ExtendedCommitStats{
  160. Author: &api.CommitUser{
  161. Identity: api.Identity{
  162. Name: authorName,
  163. Email: authorEmail,
  164. },
  165. Date: date,
  166. },
  167. Stats: &commitStats,
  168. }
  169. extendedCommitStats = append(extendedCommitStats, res)
  170. }
  171. _ = stdoutReader.Close()
  172. return nil
  173. },
  174. })
  175. if err != nil {
  176. return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr)
  177. }
  178. return extendedCommitStats, nil
  179. }
  180. func generateContributorStats(genDone chan struct{}, cache cache.Cache, cacheKey string, repo *repo_model.Repository, revision string) {
  181. ctx := graceful.GetManager().HammerContext()
  182. gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
  183. if err != nil {
  184. err := fmt.Errorf("OpenRepository: %w", err)
  185. _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
  186. return
  187. }
  188. defer closer.Close()
  189. if len(revision) == 0 {
  190. revision = repo.DefaultBranch
  191. }
  192. extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
  193. if err != nil {
  194. err := fmt.Errorf("ExtendedCommitStats: %w", err)
  195. _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
  196. return
  197. }
  198. if len(extendedCommitStats) == 0 {
  199. err := fmt.Errorf("no commit stats returned for revision '%s'", revision)
  200. _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
  201. return
  202. }
  203. layout := time.DateOnly
  204. unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
  205. contributorsCommitStats := make(map[string]*ContributorData)
  206. contributorsCommitStats["total"] = &ContributorData{
  207. Name: "Total",
  208. Weeks: make(map[int64]*WeekData),
  209. }
  210. total := contributorsCommitStats["total"]
  211. for _, v := range extendedCommitStats {
  212. userEmail := v.Author.Email
  213. if len(userEmail) == 0 {
  214. continue
  215. }
  216. u, _ := user_model.GetUserByEmail(ctx, userEmail)
  217. if u != nil {
  218. // update userEmail with user's primary email address so
  219. // that different mail addresses will linked to same account
  220. userEmail = u.GetEmail()
  221. }
  222. // duplicated logic
  223. if _, ok := contributorsCommitStats[userEmail]; !ok {
  224. if u == nil {
  225. avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
  226. if avatarLink == "" {
  227. avatarLink = unknownUserAvatarLink
  228. }
  229. contributorsCommitStats[userEmail] = &ContributorData{
  230. Name: v.Author.Name,
  231. AvatarLink: avatarLink,
  232. Weeks: make(map[int64]*WeekData),
  233. }
  234. } else {
  235. contributorsCommitStats[userEmail] = &ContributorData{
  236. Name: u.DisplayName(),
  237. Login: u.LowerName,
  238. AvatarLink: u.AvatarLinkWithSize(ctx, 0),
  239. HomeLink: u.HomeLink(),
  240. Weeks: make(map[int64]*WeekData),
  241. }
  242. }
  243. }
  244. // Update user statistics
  245. user := contributorsCommitStats[userEmail]
  246. startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
  247. val, _ := time.Parse(layout, startingOfWeek)
  248. week := val.UnixMilli()
  249. if user.Weeks[week] == nil {
  250. user.Weeks[week] = &WeekData{
  251. Additions: 0,
  252. Deletions: 0,
  253. Commits: 0,
  254. Week: week,
  255. }
  256. }
  257. if total.Weeks[week] == nil {
  258. total.Weeks[week] = &WeekData{
  259. Additions: 0,
  260. Deletions: 0,
  261. Commits: 0,
  262. Week: week,
  263. }
  264. }
  265. user.Weeks[week].Additions += v.Stats.Additions
  266. user.Weeks[week].Deletions += v.Stats.Deletions
  267. user.Weeks[week].Commits++
  268. user.TotalCommits++
  269. // Update overall statistics
  270. total.Weeks[week].Additions += v.Stats.Additions
  271. total.Weeks[week].Deletions += v.Stats.Deletions
  272. total.Weeks[week].Commits++
  273. total.TotalCommits++
  274. }
  275. _ = cache.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
  276. generateLock.Delete(cacheKey)
  277. if genDone != nil {
  278. genDone <- struct{}{}
  279. }
  280. }