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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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/cache"
  18. "code.gitea.io/gitea/modules/git"
  19. "code.gitea.io/gitea/modules/gitrepo"
  20. "code.gitea.io/gitea/modules/graceful"
  21. "code.gitea.io/gitea/modules/log"
  22. api "code.gitea.io/gitea/modules/structs"
  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.StringCache, 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 multiple 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. var res map[string]*ContributorData
  91. if _, cacheErr := cache.GetJSON(cacheKey, &res); cacheErr != nil {
  92. return nil, fmt.Errorf("cached error: %w", cacheErr.ToError())
  93. }
  94. return res, nil
  95. }
  96. // getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
  97. func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
  98. baseCommit, err := repo.GetCommit(revision)
  99. if err != nil {
  100. return nil, err
  101. }
  102. stdoutReader, stdoutWriter, err := os.Pipe()
  103. if err != nil {
  104. return nil, err
  105. }
  106. defer func() {
  107. _ = stdoutReader.Close()
  108. _ = stdoutWriter.Close()
  109. }()
  110. gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
  111. // AddOptionFormat("--max-count=%d", limit)
  112. gitCmd.AddDynamicArguments(baseCommit.ID.String())
  113. var extendedCommitStats []*ExtendedCommitStats
  114. stderr := new(strings.Builder)
  115. err = gitCmd.Run(&git.RunOpts{
  116. Dir: repo.Path,
  117. Stdout: stdoutWriter,
  118. Stderr: stderr,
  119. PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
  120. _ = stdoutWriter.Close()
  121. scanner := bufio.NewScanner(stdoutReader)
  122. for scanner.Scan() {
  123. line := strings.TrimSpace(scanner.Text())
  124. if line != "---" {
  125. continue
  126. }
  127. scanner.Scan()
  128. authorName := strings.TrimSpace(scanner.Text())
  129. scanner.Scan()
  130. authorEmail := strings.TrimSpace(scanner.Text())
  131. scanner.Scan()
  132. date := strings.TrimSpace(scanner.Text())
  133. scanner.Scan()
  134. stats := strings.TrimSpace(scanner.Text())
  135. if authorName == "" || authorEmail == "" || date == "" || stats == "" {
  136. // FIXME: find a better way to parse the output so that we will handle this properly
  137. log.Warn("Something is wrong with git log output, skipping...")
  138. log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats)
  139. continue
  140. }
  141. // 1 file changed, 1 insertion(+), 1 deletion(-)
  142. fields := strings.Split(stats, ",")
  143. commitStats := api.CommitStats{}
  144. for _, field := range fields[1:] {
  145. parts := strings.Split(strings.TrimSpace(field), " ")
  146. value, contributionType := parts[0], parts[1]
  147. amount, _ := strconv.Atoi(value)
  148. if strings.HasPrefix(contributionType, "insertion") {
  149. commitStats.Additions = amount
  150. } else {
  151. commitStats.Deletions = amount
  152. }
  153. }
  154. commitStats.Total = commitStats.Additions + commitStats.Deletions
  155. scanner.Text() // empty line at the end
  156. res := &ExtendedCommitStats{
  157. Author: &api.CommitUser{
  158. Identity: api.Identity{
  159. Name: authorName,
  160. Email: authorEmail,
  161. },
  162. Date: date,
  163. },
  164. Stats: &commitStats,
  165. }
  166. extendedCommitStats = append(extendedCommitStats, res)
  167. }
  168. _ = stdoutReader.Close()
  169. return nil
  170. },
  171. })
  172. if err != nil {
  173. return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr)
  174. }
  175. return extendedCommitStats, nil
  176. }
  177. func generateContributorStats(genDone chan struct{}, cache cache.StringCache, cacheKey string, repo *repo_model.Repository, revision string) {
  178. ctx := graceful.GetManager().HammerContext()
  179. gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
  180. if err != nil {
  181. _ = cache.PutJSON(cacheKey, fmt.Errorf("OpenRepository: %w", err), contributorStatsCacheTimeout)
  182. return
  183. }
  184. defer closer.Close()
  185. if len(revision) == 0 {
  186. revision = repo.DefaultBranch
  187. }
  188. extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
  189. if err != nil {
  190. _ = cache.PutJSON(cacheKey, fmt.Errorf("ExtendedCommitStats: %w", err), contributorStatsCacheTimeout)
  191. return
  192. }
  193. if len(extendedCommitStats) == 0 {
  194. _ = cache.PutJSON(cacheKey, fmt.Errorf("no commit stats returned for revision '%s'", revision), contributorStatsCacheTimeout)
  195. return
  196. }
  197. layout := time.DateOnly
  198. unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
  199. contributorsCommitStats := make(map[string]*ContributorData)
  200. contributorsCommitStats["total"] = &ContributorData{
  201. Name: "Total",
  202. Weeks: make(map[int64]*WeekData),
  203. }
  204. total := contributorsCommitStats["total"]
  205. for _, v := range extendedCommitStats {
  206. userEmail := v.Author.Email
  207. if len(userEmail) == 0 {
  208. continue
  209. }
  210. u, _ := user_model.GetUserByEmail(ctx, userEmail)
  211. if u != nil {
  212. // update userEmail with user's primary email address so
  213. // that different mail addresses will linked to same account
  214. userEmail = u.GetEmail()
  215. }
  216. // duplicated logic
  217. if _, ok := contributorsCommitStats[userEmail]; !ok {
  218. if u == nil {
  219. avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
  220. if avatarLink == "" {
  221. avatarLink = unknownUserAvatarLink
  222. }
  223. contributorsCommitStats[userEmail] = &ContributorData{
  224. Name: v.Author.Name,
  225. AvatarLink: avatarLink,
  226. Weeks: make(map[int64]*WeekData),
  227. }
  228. } else {
  229. contributorsCommitStats[userEmail] = &ContributorData{
  230. Name: u.DisplayName(),
  231. Login: u.LowerName,
  232. AvatarLink: u.AvatarLinkWithSize(ctx, 0),
  233. HomeLink: u.HomeLink(),
  234. Weeks: make(map[int64]*WeekData),
  235. }
  236. }
  237. }
  238. // Update user statistics
  239. user := contributorsCommitStats[userEmail]
  240. startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
  241. val, _ := time.Parse(layout, startingOfWeek)
  242. week := val.UnixMilli()
  243. if user.Weeks[week] == nil {
  244. user.Weeks[week] = &WeekData{
  245. Additions: 0,
  246. Deletions: 0,
  247. Commits: 0,
  248. Week: week,
  249. }
  250. }
  251. if total.Weeks[week] == nil {
  252. total.Weeks[week] = &WeekData{
  253. Additions: 0,
  254. Deletions: 0,
  255. Commits: 0,
  256. Week: week,
  257. }
  258. }
  259. user.Weeks[week].Additions += v.Stats.Additions
  260. user.Weeks[week].Deletions += v.Stats.Deletions
  261. user.Weeks[week].Commits++
  262. user.TotalCommits++
  263. // Update overall statistics
  264. total.Weeks[week].Additions += v.Stats.Additions
  265. total.Weeks[week].Deletions += v.Stats.Deletions
  266. total.Weeks[week].Commits++
  267. total.TotalCommits++
  268. }
  269. _ = cache.PutJSON(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
  270. generateLock.Delete(cacheKey)
  271. if genDone != nil {
  272. genDone <- struct{}{}
  273. }
  274. }