loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement contributors graph (#27882)

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)

authored by

Şahin Akkaya
silverwind
hiifong
delvh
6543
yp05327
and committed by
Earl Warren
e9be8b25 1f8ad34e

+1330 -230
+12
options/locale/locale_en-US.ini
··· 1957 1957 wiki.original_git_entry_tooltip = View original Git file instead of using friendly link. 1958 1958 1959 1959 activity = Activity 1960 + activity.navbar.pulse = Pulse 1961 + activity.navbar.contributors = Contributors 1960 1962 activity.period.filter_label = Period: 1961 1963 activity.period.daily = 1 day 1962 1964 activity.period.halfweekly = 3 days ··· 2021 2023 activity.git_stats_and_deletions = and 2022 2024 activity.git_stats_deletion_1 = %d deletion 2023 2025 activity.git_stats_deletion_n = %d deletions 2026 + 2027 + contributors = Contributors 2028 + contributors.contribution_type.filter_label = Contribution type: 2029 + contributors.contribution_type.commits = Commits 2030 + contributors.contribution_type.additions = Additions 2031 + contributors.contribution_type.deletions = Deletions 2032 + contributors.loading_title = Loading contributions... 2033 + contributors.loading_title_failed = Could not load contributions 2034 + contributors.loading_info = This might take a bit… 2035 + contributors.component_failed_to_load = An unexpected error happened. 2024 2036 2025 2037 search = Search 2026 2038 search.search_repo = Search repository
+64 -3
package-lock.json
··· 19 19 "add-asset-webpack-plugin": "2.0.1", 20 20 "ansi_up": "6.0.2", 21 21 "asciinema-player": "3.6.3", 22 + "chart.js": "4.3.0", 23 + "chartjs-adapter-dayjs-4": "1.0.4", 24 + "chartjs-plugin-zoom": "2.0.1", 22 25 "clippie": "4.0.6", 23 26 "css-loader": "6.10.0", 27 + "dayjs": "1.11.10", 24 28 "dropzone": "6.0.0-beta.2", 25 29 "easymde": "2.18.0", 26 30 "esbuild-loader": "4.0.3", ··· 47 51 "uint8-to-base64": "0.2.0", 48 52 "vue": "3.4.18", 49 53 "vue-bar-graph": "2.0.0", 54 + "vue-chartjs": "5.3.0", 50 55 "vue-loader": "17.4.2", 51 56 "vue3-calendar-heatmap": "2.0.5", 52 57 "webpack": "5.90.1", ··· 1277 1282 "peerDependencies": { 1278 1283 "jsep": "^0.4.0||^1.0.0" 1279 1284 } 1285 + }, 1286 + "node_modules/@kurkle/color": { 1287 + "version": "0.3.2", 1288 + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", 1289 + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" 1280 1290 }, 1281 1291 "node_modules/@mcaptcha/core-glue": { 1282 1292 "version": "0.1.0-alpha-5", ··· 3329 3339 "url": "https://github.com/sponsors/wooorm" 3330 3340 } 3331 3341 }, 3342 + "node_modules/chart.js": { 3343 + "version": "4.3.0", 3344 + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", 3345 + "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", 3346 + "dependencies": { 3347 + "@kurkle/color": "^0.3.0" 3348 + }, 3349 + "engines": { 3350 + "pnpm": ">=7" 3351 + } 3352 + }, 3353 + "node_modules/chartjs-adapter-dayjs-4": { 3354 + "version": "1.0.4", 3355 + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", 3356 + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", 3357 + "engines": { 3358 + "node": ">=10" 3359 + }, 3360 + "peerDependencies": { 3361 + "chart.js": ">=4.0.1", 3362 + "dayjs": "^1.9.7" 3363 + } 3364 + }, 3365 + "node_modules/chartjs-plugin-zoom": { 3366 + "version": "2.0.1", 3367 + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz", 3368 + "integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==", 3369 + "dependencies": { 3370 + "hammerjs": "^2.0.8" 3371 + }, 3372 + "peerDependencies": { 3373 + "chart.js": ">=3.2.0" 3374 + } 3375 + }, 3332 3376 "node_modules/check-error": { 3333 3377 "version": "1.0.3", 3334 3378 "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", ··· 5868 5912 "dev": true 5869 5913 }, 5870 5914 "node_modules/gsap": { 5871 - "version": "3.12.5", 5872 - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.5.tgz", 5873 - "integrity": "sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ==" 5915 + "version": "3.12.2", 5916 + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.2.tgz", 5917 + "integrity": "sha512-EkYnpG8qHgYBFAwsgsGEqvT1WUidX0tt/ijepx7z8EUJHElykg91RvW1XbkT59T0gZzzszOpjQv7SE41XuIXyQ==" 5918 + }, 5919 + "node_modules/hammerjs": { 5920 + "version": "2.0.8", 5921 + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", 5922 + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", 5923 + "engines": { 5924 + "node": ">=0.8.0" 5925 + } 5874 5926 }, 5875 5927 "node_modules/has-bigints": { 5876 5928 "version": "1.0.2", ··· 10932 10984 "dependencies": { 10933 10985 "gsap": "^3.10.4", 10934 10986 "vue": "^3.2.37" 10987 + } 10988 + }, 10989 + "node_modules/vue-chartjs": { 10990 + "version": "5.3.0", 10991 + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.0.tgz", 10992 + "integrity": "sha512-8XqX0JU8vFZ+WA2/knz4z3ThClduni2Nm0BMe2u0mXgTfd9pXrmJ07QBI+WAij5P/aPmPMX54HCE1seWL37ZdQ==", 10993 + "peerDependencies": { 10994 + "chart.js": "^4.1.1", 10995 + "vue": "^3.0.0-0 || ^2.7.0" 10935 10996 } 10936 10997 }, 10937 10998 "node_modules/vue-eslint-parser": {
+5
package.json
··· 18 18 "add-asset-webpack-plugin": "2.0.1", 19 19 "ansi_up": "6.0.2", 20 20 "asciinema-player": "3.6.3", 21 + "chart.js": "4.3.0", 22 + "chartjs-adapter-dayjs-4": "1.0.4", 23 + "chartjs-plugin-zoom": "2.0.1", 21 24 "clippie": "4.0.6", 22 25 "css-loader": "6.10.0", 26 + "dayjs": "1.11.10", 23 27 "dropzone": "6.0.0-beta.2", 24 28 "easymde": "2.18.0", 25 29 "esbuild-loader": "4.0.3", ··· 46 50 "uint8-to-base64": "0.2.0", 47 51 "vue": "3.4.18", 48 52 "vue-bar-graph": "2.0.0", 53 + "vue-chartjs": "5.3.0", 49 54 "vue-loader": "17.4.2", 50 55 "vue3-calendar-heatmap": "2.0.5", 51 56 "webpack": "5.90.1",
+2
routers/web/repo/activity.go
··· 22 22 ctx.Data["Title"] = ctx.Tr("repo.activity") 23 23 ctx.Data["PageIsActivity"] = true 24 24 25 + ctx.Data["PageIsPulse"] = true 26 + 25 27 ctx.Data["Period"] = ctx.Params("period") 26 28 27 29 timeUntil := time.Now()
+44
routers/web/repo/contributors.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package repo 5 + 6 + import ( 7 + "errors" 8 + "net/http" 9 + 10 + "code.gitea.io/gitea/modules/base" 11 + "code.gitea.io/gitea/modules/context" 12 + contributors_service "code.gitea.io/gitea/services/repository" 13 + ) 14 + 15 + const ( 16 + tplContributors base.TplName = "repo/activity" 17 + ) 18 + 19 + // Contributors render the page to show repository contributors graph 20 + func Contributors(ctx *context.Context) { 21 + ctx.Data["Title"] = ctx.Tr("repo.contributors") 22 + 23 + ctx.Data["PageIsActivity"] = true 24 + ctx.Data["PageIsContributors"] = true 25 + 26 + ctx.PageData["contributionType"] = "commits" 27 + 28 + ctx.PageData["repoLink"] = ctx.Repo.RepoLink 29 + 30 + ctx.HTML(http.StatusOK, tplContributors) 31 + } 32 + 33 + // ContributorsData renders JSON of contributors along with their weekly commit statistics 34 + func ContributorsData(ctx *context.Context) { 35 + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { 36 + if errors.Is(err, contributors_service.ErrAwaitGeneration) { 37 + ctx.Status(http.StatusAccepted) 38 + return 39 + } 40 + ctx.ServerError("GetContributorStats", err) 41 + } else { 42 + ctx.JSON(http.StatusOK, contributorStats) 43 + } 44 + }
+4
routers/web/web.go
··· 1431 1431 m.Group("/activity", func() { 1432 1432 m.Get("", repo.Activity) 1433 1433 m.Get("/{period}", repo.Activity) 1434 + m.Group("/contributors", func() { 1435 + m.Get("", repo.Contributors) 1436 + m.Get("/data", repo.ContributorsData) 1437 + }) 1434 1438 }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) 1435 1439 1436 1440 m.Group("/activity_author_data", func() {
+319
services/repository/contributors_graph.go
··· 1 + // Copyright 2023 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package repository 5 + 6 + import ( 7 + "bufio" 8 + "context" 9 + "errors" 10 + "fmt" 11 + "os" 12 + "strconv" 13 + "strings" 14 + "sync" 15 + "time" 16 + 17 + "code.gitea.io/gitea/models/avatars" 18 + repo_model "code.gitea.io/gitea/models/repo" 19 + user_model "code.gitea.io/gitea/models/user" 20 + "code.gitea.io/gitea/modules/git" 21 + "code.gitea.io/gitea/modules/gitrepo" 22 + "code.gitea.io/gitea/modules/graceful" 23 + "code.gitea.io/gitea/modules/log" 24 + api "code.gitea.io/gitea/modules/structs" 25 + 26 + "gitea.com/go-chi/cache" 27 + ) 28 + 29 + const ( 30 + contributorStatsCacheKey = "GetContributorStats/%s/%s" 31 + contributorStatsCacheTimeout int64 = 60 * 10 32 + ) 33 + 34 + var ( 35 + ErrAwaitGeneration = errors.New("generation took longer than ") 36 + awaitGenerationTime = time.Second * 5 37 + generateLock = sync.Map{} 38 + ) 39 + 40 + type WeekData struct { 41 + Week int64 `json:"week"` // Starting day of the week as Unix timestamp 42 + Additions int `json:"additions"` // Number of additions in that week 43 + Deletions int `json:"deletions"` // Number of deletions in that week 44 + Commits int `json:"commits"` // Number of commits in that week 45 + } 46 + 47 + // ContributorData represents statistical git commit count data 48 + type ContributorData struct { 49 + Name string `json:"name"` // Display name of the contributor 50 + Login string `json:"login"` // Login name of the contributor in case it exists 51 + AvatarLink string `json:"avatar_link"` 52 + HomeLink string `json:"home_link"` 53 + TotalCommits int64 `json:"total_commits"` 54 + Weeks map[int64]*WeekData `json:"weeks"` 55 + } 56 + 57 + // ExtendedCommitStats contains information for commit stats with author data 58 + type ExtendedCommitStats struct { 59 + Author *api.CommitUser `json:"author"` 60 + Stats *api.CommitStats `json:"stats"` 61 + } 62 + 63 + const layout = time.DateOnly 64 + 65 + func findLastSundayBeforeDate(dateStr string) (string, error) { 66 + date, err := time.Parse(layout, dateStr) 67 + if err != nil { 68 + return "", err 69 + } 70 + 71 + weekday := date.Weekday() 72 + daysToSubtract := int(weekday) - int(time.Sunday) 73 + if daysToSubtract < 0 { 74 + daysToSubtract += 7 75 + } 76 + 77 + lastSunday := date.AddDate(0, 0, -daysToSubtract) 78 + return lastSunday.Format(layout), nil 79 + } 80 + 81 + // GetContributorStats returns contributors stats for git commits for given revision or default branch 82 + func GetContributorStats(ctx context.Context, cache cache.Cache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) { 83 + // as GetContributorStats is resource intensive we cache the result 84 + cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision) 85 + if !cache.IsExist(cacheKey) { 86 + genReady := make(chan struct{}) 87 + 88 + // dont start multible async generations 89 + _, run := generateLock.Load(cacheKey) 90 + if run { 91 + return nil, ErrAwaitGeneration 92 + } 93 + 94 + generateLock.Store(cacheKey, struct{}{}) 95 + // run generation async 96 + go generateContributorStats(genReady, cache, cacheKey, repo, revision) 97 + 98 + select { 99 + case <-time.After(awaitGenerationTime): 100 + return nil, ErrAwaitGeneration 101 + case <-genReady: 102 + // we got generation ready before timeout 103 + break 104 + } 105 + } 106 + // TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout) 107 + 108 + switch v := cache.Get(cacheKey).(type) { 109 + case error: 110 + return nil, v 111 + case map[string]*ContributorData: 112 + return v, nil 113 + default: 114 + return nil, fmt.Errorf("unexpected type in cache detected") 115 + } 116 + } 117 + 118 + // getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision 119 + func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) { 120 + baseCommit, err := repo.GetCommit(revision) 121 + if err != nil { 122 + return nil, err 123 + } 124 + stdoutReader, stdoutWriter, err := os.Pipe() 125 + if err != nil { 126 + return nil, err 127 + } 128 + defer func() { 129 + _ = stdoutReader.Close() 130 + _ = stdoutWriter.Close() 131 + }() 132 + 133 + gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse") 134 + // AddOptionFormat("--max-count=%d", limit) 135 + gitCmd.AddDynamicArguments(baseCommit.ID.String()) 136 + 137 + var extendedCommitStats []*ExtendedCommitStats 138 + stderr := new(strings.Builder) 139 + err = gitCmd.Run(&git.RunOpts{ 140 + Dir: repo.Path, 141 + Stdout: stdoutWriter, 142 + Stderr: stderr, 143 + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { 144 + _ = stdoutWriter.Close() 145 + scanner := bufio.NewScanner(stdoutReader) 146 + scanner.Split(bufio.ScanLines) 147 + 148 + for scanner.Scan() { 149 + line := strings.TrimSpace(scanner.Text()) 150 + if line != "---" { 151 + continue 152 + } 153 + scanner.Scan() 154 + authorName := strings.TrimSpace(scanner.Text()) 155 + scanner.Scan() 156 + authorEmail := strings.TrimSpace(scanner.Text()) 157 + scanner.Scan() 158 + date := strings.TrimSpace(scanner.Text()) 159 + scanner.Scan() 160 + stats := strings.TrimSpace(scanner.Text()) 161 + if authorName == "" || authorEmail == "" || date == "" || stats == "" { 162 + // FIXME: find a better way to parse the output so that we will handle this properly 163 + log.Warn("Something is wrong with git log output, skipping...") 164 + log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats) 165 + continue 166 + } 167 + // 1 file changed, 1 insertion(+), 1 deletion(-) 168 + fields := strings.Split(stats, ",") 169 + 170 + commitStats := api.CommitStats{} 171 + for _, field := range fields[1:] { 172 + parts := strings.Split(strings.TrimSpace(field), " ") 173 + value, contributionType := parts[0], parts[1] 174 + amount, _ := strconv.Atoi(value) 175 + 176 + if strings.HasPrefix(contributionType, "insertion") { 177 + commitStats.Additions = amount 178 + } else { 179 + commitStats.Deletions = amount 180 + } 181 + } 182 + commitStats.Total = commitStats.Additions + commitStats.Deletions 183 + scanner.Scan() 184 + scanner.Text() // empty line at the end 185 + 186 + res := &ExtendedCommitStats{ 187 + Author: &api.CommitUser{ 188 + Identity: api.Identity{ 189 + Name: authorName, 190 + Email: authorEmail, 191 + }, 192 + Date: date, 193 + }, 194 + Stats: &commitStats, 195 + } 196 + extendedCommitStats = append(extendedCommitStats, res) 197 + 198 + } 199 + _ = stdoutReader.Close() 200 + return nil 201 + }, 202 + }) 203 + if err != nil { 204 + return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr) 205 + } 206 + 207 + return extendedCommitStats, nil 208 + } 209 + 210 + func generateContributorStats(genDone chan struct{}, cache cache.Cache, cacheKey string, repo *repo_model.Repository, revision string) { 211 + ctx := graceful.GetManager().HammerContext() 212 + 213 + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) 214 + if err != nil { 215 + err := fmt.Errorf("OpenRepository: %w", err) 216 + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) 217 + return 218 + } 219 + defer closer.Close() 220 + 221 + if len(revision) == 0 { 222 + revision = repo.DefaultBranch 223 + } 224 + extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision) 225 + if err != nil { 226 + err := fmt.Errorf("ExtendedCommitStats: %w", err) 227 + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) 228 + return 229 + } 230 + if len(extendedCommitStats) == 0 { 231 + err := fmt.Errorf("no commit stats returned for revision '%s'", revision) 232 + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) 233 + return 234 + } 235 + 236 + layout := time.DateOnly 237 + 238 + unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0) 239 + contributorsCommitStats := make(map[string]*ContributorData) 240 + contributorsCommitStats["total"] = &ContributorData{ 241 + Name: "Total", 242 + Weeks: make(map[int64]*WeekData), 243 + } 244 + total := contributorsCommitStats["total"] 245 + 246 + for _, v := range extendedCommitStats { 247 + userEmail := v.Author.Email 248 + if len(userEmail) == 0 { 249 + continue 250 + } 251 + u, _ := user_model.GetUserByEmail(ctx, userEmail) 252 + if u != nil { 253 + // update userEmail with user's primary email address so 254 + // that different mail addresses will linked to same account 255 + userEmail = u.GetEmail() 256 + } 257 + // duplicated logic 258 + if _, ok := contributorsCommitStats[userEmail]; !ok { 259 + if u == nil { 260 + avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0) 261 + if avatarLink == "" { 262 + avatarLink = unknownUserAvatarLink 263 + } 264 + contributorsCommitStats[userEmail] = &ContributorData{ 265 + Name: v.Author.Name, 266 + AvatarLink: avatarLink, 267 + Weeks: make(map[int64]*WeekData), 268 + } 269 + } else { 270 + contributorsCommitStats[userEmail] = &ContributorData{ 271 + Name: u.DisplayName(), 272 + Login: u.LowerName, 273 + AvatarLink: u.AvatarLinkWithSize(ctx, 0), 274 + HomeLink: u.HomeLink(), 275 + Weeks: make(map[int64]*WeekData), 276 + } 277 + } 278 + } 279 + // Update user statistics 280 + user := contributorsCommitStats[userEmail] 281 + startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date) 282 + 283 + val, _ := time.Parse(layout, startingOfWeek) 284 + week := val.UnixMilli() 285 + 286 + if user.Weeks[week] == nil { 287 + user.Weeks[week] = &WeekData{ 288 + Additions: 0, 289 + Deletions: 0, 290 + Commits: 0, 291 + Week: week, 292 + } 293 + } 294 + if total.Weeks[week] == nil { 295 + total.Weeks[week] = &WeekData{ 296 + Additions: 0, 297 + Deletions: 0, 298 + Commits: 0, 299 + Week: week, 300 + } 301 + } 302 + user.Weeks[week].Additions += v.Stats.Additions 303 + user.Weeks[week].Deletions += v.Stats.Deletions 304 + user.Weeks[week].Commits++ 305 + user.TotalCommits++ 306 + 307 + // Update overall statistics 308 + total.Weeks[week].Additions += v.Stats.Additions 309 + total.Weeks[week].Deletions += v.Stats.Deletions 310 + total.Weeks[week].Commits++ 311 + total.TotalCommits++ 312 + } 313 + 314 + _ = cache.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout) 315 + generateLock.Delete(cacheKey) 316 + if genDone != nil { 317 + genDone <- struct{}{} 318 + } 319 + }
+87
services/repository/contributors_graph_test.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package repository 5 + 6 + import ( 7 + "slices" 8 + "testing" 9 + 10 + "code.gitea.io/gitea/models/db" 11 + repo_model "code.gitea.io/gitea/models/repo" 12 + "code.gitea.io/gitea/models/unittest" 13 + "code.gitea.io/gitea/modules/git" 14 + 15 + "gitea.com/go-chi/cache" 16 + "github.com/stretchr/testify/assert" 17 + ) 18 + 19 + func TestRepository_ContributorsGraph(t *testing.T) { 20 + assert.NoError(t, unittest.PrepareTestDatabase()) 21 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) 22 + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) 23 + mockCache, err := cache.NewCacher(cache.Options{ 24 + Adapter: "memory", 25 + Interval: 24 * 60, 26 + }) 27 + assert.NoError(t, err) 28 + 29 + generateContributorStats(nil, mockCache, "key", repo, "404ref") 30 + err, isErr := mockCache.Get("key").(error) 31 + assert.True(t, isErr) 32 + assert.ErrorAs(t, err, &git.ErrNotExist{}) 33 + 34 + generateContributorStats(nil, mockCache, "key2", repo, "master") 35 + data, isData := mockCache.Get("key2").(map[string]*ContributorData) 36 + assert.True(t, isData) 37 + var keys []string 38 + for k := range data { 39 + keys = append(keys, k) 40 + } 41 + slices.Sort(keys) 42 + assert.EqualValues(t, []string{ 43 + "ethantkoenig@gmail.com", 44 + "jimmy.praet@telenet.be", 45 + "jon@allspice.io", 46 + "total", // generated summary 47 + }, keys) 48 + 49 + assert.EqualValues(t, &ContributorData{ 50 + Name: "Ethan Koenig", 51 + AvatarLink: "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon", 52 + TotalCommits: 1, 53 + Weeks: map[int64]*WeekData{ 54 + 1511654400000: { 55 + Week: 1511654400000, // sunday 2017-11-26 56 + Additions: 3, 57 + Deletions: 0, 58 + Commits: 1, 59 + }, 60 + }, 61 + }, data["ethantkoenig@gmail.com"]) 62 + assert.EqualValues(t, &ContributorData{ 63 + Name: "Total", 64 + AvatarLink: "", 65 + TotalCommits: 3, 66 + Weeks: map[int64]*WeekData{ 67 + 1511654400000: { 68 + Week: 1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800) 69 + Additions: 3, 70 + Deletions: 0, 71 + Commits: 1, 72 + }, 73 + 1607817600000: { 74 + Week: 1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500) 75 + Additions: 10, 76 + Deletions: 0, 77 + Commits: 1, 78 + }, 79 + 1624752000000: { 80 + Week: 1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200) 81 + Additions: 2, 82 + Deletions: 0, 83 + Commits: 1, 84 + }, 85 + }, 86 + }, data["total"]) 87 + }
+7 -227
templates/repo/activity.tmpl
··· 1 1 {{template "base/head" .}} 2 2 <div role="main" aria-label="{{.Title}}" class="page-content repository commits"> 3 3 {{template "repo/header" .}} 4 - <div class="ui container"> 5 - <h2 class="ui header activity-header"> 6 - <span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span> 7 - <!-- Period --> 8 - <div class="ui floating dropdown jump filter"> 9 - <div class="ui basic compact button"> 10 - {{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong> 11 - {{svg "octicon-triangle-down" 14 "dropdown icon"}} 12 - </div> 13 - <div class="menu"> 14 - <a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a> 15 - <a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a> 16 - <a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a> 17 - <a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a> 18 - <a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a> 19 - <a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a> 20 - <a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a> 21 - </div> 22 - </div> 23 - </h2> 24 - <div class="divider"></div> 25 - 26 - {{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}} 27 - <h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4> 28 - <div class="ui attached segment two column grid"> 29 - {{if .Permission.CanRead $.UnitTypePullRequests}} 30 - <div class="column"> 31 - {{if gt .Activity.ActivePRCount 0}} 32 - <div class="stats-table"> 33 - <a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a> 34 - <a href="#proposed-pull-requests" class="table-cell tiny background green"></a> 35 - </div> 36 - {{else}} 37 - <div class="stats-table"> 38 - <a class="table-cell tiny background light grey"></a> 39 - </div> 40 - {{end}} 41 - {{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}} 42 - </div> 43 - {{end}} 44 - {{if .Permission.CanRead $.UnitTypeIssues}} 45 - <div class="column"> 46 - {{if gt .Activity.ActiveIssueCount 0}} 47 - <div class="stats-table"> 48 - <a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a> 49 - <a href="#new-issues" class="table-cell tiny background green"></a> 50 - </div> 51 - {{else}} 52 - <div class="stats-table"> 53 - <a class="table-cell tiny background light grey"></a> 54 - </div> 55 - {{end}} 56 - {{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}} 57 - </div> 58 - {{end}} 4 + <div class="ui container flex-container"> 5 + <div class="flex-container-nav"> 6 + {{template "repo/navbar" .}} 59 7 </div> 60 - <div class="ui attached segment horizontal segments"> 61 - {{if .Permission.CanRead $.UnitTypePullRequests}} 62 - <a href="#merged-pull-requests" class="ui attached segment text center"> 63 - <span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br> 64 - {{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}} 65 - </a> 66 - <a href="#proposed-pull-requests" class="ui attached segment text center"> 67 - <span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br> 68 - {{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}} 69 - </a> 70 - {{end}} 71 - {{if .Permission.CanRead $.UnitTypeIssues}} 72 - <a href="#closed-issues" class="ui attached segment text center"> 73 - <span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br> 74 - {{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}} 75 - </a> 76 - <a href="#new-issues" class="ui attached segment text center"> 77 - <span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br> 78 - {{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}} 79 - </a> 80 - {{end}} 8 + <div class="flex-container-main"> 9 + {{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}} 10 + {{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}} 81 11 </div> 82 - {{end}} 83 - 84 - {{if .Permission.CanRead $.UnitTypeCode}} 85 - {{if eq .Activity.Code.CommitCountInAllBranches 0}} 86 - <div class="ui center aligned segment"> 87 - <h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4> 88 - </div> 89 - {{end}} 90 - {{if gt .Activity.Code.CommitCountInAllBranches 0}} 91 - <div class="ui attached segment horizontal segments"> 92 - <div class="ui attached segment text"> 93 - {{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}} 94 - <strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong> 95 - {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}} 96 - <strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong> 97 - {{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}} 98 - <strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong> 99 - {{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}} 100 - {{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}} 101 - <strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong> 102 - {{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}} 103 - {{ctx.Locale.Tr "repo.activity.git_stats_additions"}} 104 - <strong class="text green">{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}</strong> 105 - {{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}} 106 - <strong class="text red">{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}</strong>. 107 - </div> 108 - <div class="ui attached segment"> 109 - <div id="repo-activity-top-authors-chart"></div> 110 - </div> 111 - </div> 112 - {{end}} 113 - {{end}} 114 - 115 - {{if gt .Activity.PublishedReleaseCount 0}} 116 - <h4 class="divider divider-text gt-normal-case" id="published-releases"> 117 - {{svg "octicon-tag" 16 "gt-mr-3"}} 118 - {{ctx.Locale.Tr "repo.activity.title.releases_published_by" 119 - (ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount) 120 - (ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount) 121 - }} 122 - </h4> 123 - <div class="list"> 124 - {{range .Activity.PublishedReleases}} 125 - <p class="desc"> 126 - <span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span> 127 - {{.TagName}} 128 - {{if not .IsTag}} 129 - <a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 130 - {{end}} 131 - {{TimeSinceUnix .CreatedUnix ctx.Locale}} 132 - </p> 133 - {{end}} 134 - </div> 135 - {{end}} 136 - 137 - {{if gt .Activity.MergedPRCount 0}} 138 - <h4 class="divider divider-text gt-normal-case" id="merged-pull-requests"> 139 - {{svg "octicon-git-pull-request" 16 "gt-mr-3"}} 140 - {{ctx.Locale.Tr "repo.activity.title.prs_merged_by" 141 - (ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount) 142 - (ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount) 143 - }} 144 - </h4> 145 - <div class="list"> 146 - {{range .Activity.MergedPRs}} 147 - <p class="desc"> 148 - <span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span> 149 - #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 150 - {{TimeSinceUnix .MergedUnix ctx.Locale}} 151 - </p> 152 - {{end}} 153 - </div> 154 - {{end}} 155 - 156 - {{if gt .Activity.OpenedPRCount 0}} 157 - <h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests"> 158 - {{svg "octicon-git-branch" 16 "gt-mr-3"}} 159 - {{ctx.Locale.Tr "repo.activity.title.prs_opened_by" 160 - (ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount) 161 - (ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount) 162 - }} 163 - </h4> 164 - <div class="list"> 165 - {{range .Activity.OpenedPRs}} 166 - <p class="desc"> 167 - <span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span> 168 - #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 169 - {{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} 170 - </p> 171 - {{end}} 172 - </div> 173 - {{end}} 174 - 175 - {{if gt .Activity.ClosedIssueCount 0}} 176 - <h4 class="divider divider-text gt-normal-case" id="closed-issues"> 177 - {{svg "octicon-issue-closed" 16 "gt-mr-3"}} 178 - {{ctx.Locale.Tr "repo.activity.title.issues_closed_from" 179 - (ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount) 180 - (ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount) 181 - }} 182 - </h4> 183 - <div class="list"> 184 - {{range .Activity.ClosedIssues}} 185 - <p class="desc"> 186 - <span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span> 187 - #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 188 - {{TimeSinceUnix .ClosedUnix ctx.Locale}} 189 - </p> 190 - {{end}} 191 - </div> 192 - {{end}} 193 - 194 - {{if gt .Activity.OpenedIssueCount 0}} 195 - <h4 class="divider divider-text gt-normal-case" id="new-issues"> 196 - {{svg "octicon-issue-opened" 16 "gt-mr-3"}} 197 - {{ctx.Locale.Tr "repo.activity.title.issues_created_by" 198 - (ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount) 199 - (ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount) 200 - }} 201 - </h4> 202 - <div class="list"> 203 - {{range .Activity.OpenedIssues}} 204 - <p class="desc"> 205 - <span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span> 206 - #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 207 - {{TimeSinceUnix .CreatedUnix ctx.Locale}} 208 - </p> 209 - {{end}} 210 - </div> 211 - {{end}} 212 - 213 - {{if gt .Activity.UnresolvedIssueCount 0}} 214 - <h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}"> 215 - {{svg "octicon-comment-discussion" 16 "gt-mr-3"}} 216 - {{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}} 217 - </h4> 218 - <div class="list"> 219 - {{range .Activity.UnresolvedIssues}} 220 - <p class="desc"> 221 - <span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span> 222 - #{{.Index}} 223 - {{if .IsPull}} 224 - <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 225 - {{else}} 226 - <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 227 - {{end}} 228 - {{TimeSinceUnix .UpdatedUnix ctx.Locale}} 229 - </p> 230 - {{end}} 231 - </div> 232 - {{end}} 233 12 </div> 234 13 </div> 235 14 {{template "base/footer" .}} 15 +
+13
templates/repo/contributors.tmpl
··· 1 + {{if .Permission.CanRead $.UnitTypeCode}} 2 + <div id="repo-contributors-chart" 3 + data-locale-filter-label="{{ctx.Locale.Tr "repo.contributors.contribution_type.filter_label"}}" 4 + data-locale-contribution-type-commits="{{ctx.Locale.Tr "repo.contributors.contribution_type.commits"}}" 5 + data-locale-contribution-type-additions="{{ctx.Locale.Tr "repo.contributors.contribution_type.additions"}}" 6 + data-locale-contribution-type-deletions="{{ctx.Locale.Tr "repo.contributors.contribution_type.deletions"}}" 7 + data-locale-loading-title="{{ctx.Locale.Tr "repo.contributors.loading_title"}}" 8 + data-locale-loading-title-failed="{{ctx.Locale.Tr "repo.contributors.loading_title_failed"}}" 9 + data-locale-loading-info="{{ctx.Locale.Tr "repo.contributors.loading_info"}}" 10 + data-locale-component-failed-to-load="{{ctx.Locale.Tr "repo.contributors.component_failed_to_load"}}" 11 + > 12 + </div> 13 + {{end}}
+8
templates/repo/navbar.tmpl
··· 1 + <div class="ui fluid vertical menu"> 2 + <a class="{{if .PageIsPulse}}active {{end}}item" href="{{.RepoLink}}/activity"> 3 + {{ctx.Locale.Tr "repo.activity.navbar.pulse"}} 4 + </a> 5 + <a class="{{if .PageIsContributors}}active {{end}}item" href="{{.RepoLink}}/activity/contributors"> 6 + {{ctx.Locale.Tr "repo.activity.navbar.contributors"}} 7 + </a> 8 + </div>
+227
templates/repo/pulse.tmpl
··· 1 + <h2 class="ui header activity-header"> 2 + <span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span> 3 + <!-- Period --> 4 + <div class="ui floating dropdown jump filter"> 5 + <div class="ui basic compact button"> 6 + {{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong> 7 + {{svg "octicon-triangle-down" 14 "dropdown icon"}} 8 + </div> 9 + <div class="menu"> 10 + <a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a> 11 + <a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a> 12 + <a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a> 13 + <a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a> 14 + <a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a> 15 + <a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a> 16 + <a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a> 17 + </div> 18 + </div> 19 + </h2> 20 + 21 + {{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}} 22 + <h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4> 23 + <div class="ui attached segment two column grid"> 24 + {{if .Permission.CanRead $.UnitTypePullRequests}} 25 + <div class="column"> 26 + {{if gt .Activity.ActivePRCount 0}} 27 + <div class="stats-table"> 28 + <a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a> 29 + <a href="#proposed-pull-requests" class="table-cell tiny background green"></a> 30 + </div> 31 + {{else}} 32 + <div class="stats-table"> 33 + <a class="table-cell tiny background light grey"></a> 34 + </div> 35 + {{end}} 36 + {{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}} 37 + </div> 38 + {{end}} 39 + {{if .Permission.CanRead $.UnitTypeIssues}} 40 + <div class="column"> 41 + {{if gt .Activity.ActiveIssueCount 0}} 42 + <div class="stats-table"> 43 + <a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a> 44 + <a href="#new-issues" class="table-cell tiny background green"></a> 45 + </div> 46 + {{else}} 47 + <div class="stats-table"> 48 + <a class="table-cell tiny background light grey"></a> 49 + </div> 50 + {{end}} 51 + {{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}} 52 + </div> 53 + {{end}} 54 + </div> 55 + <div class="ui attached segment horizontal segments"> 56 + {{if .Permission.CanRead $.UnitTypePullRequests}} 57 + <a href="#merged-pull-requests" class="ui attached segment text center"> 58 + <span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br> 59 + {{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}} 60 + </a> 61 + <a href="#proposed-pull-requests" class="ui attached segment text center"> 62 + <span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br> 63 + {{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}} 64 + </a> 65 + {{end}} 66 + {{if .Permission.CanRead $.UnitTypeIssues}} 67 + <a href="#closed-issues" class="ui attached segment text center"> 68 + <span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br> 69 + {{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}} 70 + </a> 71 + <a href="#new-issues" class="ui attached segment text center"> 72 + <span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br> 73 + {{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}} 74 + </a> 75 + {{end}} 76 + </div> 77 + {{end}} 78 + 79 + {{if .Permission.CanRead $.UnitTypeCode}} 80 + {{if eq .Activity.Code.CommitCountInAllBranches 0}} 81 + <div class="ui center aligned segment"> 82 + <h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4> 83 + </div> 84 + {{end}} 85 + {{if gt .Activity.Code.CommitCountInAllBranches 0}} 86 + <div class="ui attached segment horizontal segments"> 87 + <div class="ui attached segment text"> 88 + {{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}} 89 + <strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong> 90 + {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}} 91 + <strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong> 92 + {{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}} 93 + <strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong> 94 + {{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}} 95 + {{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}} 96 + <strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong> 97 + {{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}} 98 + {{ctx.Locale.Tr "repo.activity.git_stats_additions"}} 99 + <strong class="text green">{{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}</strong> 100 + {{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}} 101 + <strong class="text red">{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}</strong>. 102 + </div> 103 + <div class="ui attached segment"> 104 + <div id="repo-activity-top-authors-chart"></div> 105 + </div> 106 + </div> 107 + {{end}} 108 + {{end}} 109 + 110 + {{if gt .Activity.PublishedReleaseCount 0}} 111 + <h4 class="divider divider-text gt-normal-case" id="published-releases"> 112 + {{svg "octicon-tag" 16 "gt-mr-3"}} 113 + {{ctx.Locale.Tr "repo.activity.title.releases_published_by" 114 + (ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount) 115 + (ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount) 116 + }} 117 + </h4> 118 + <div class="list"> 119 + {{range .Activity.PublishedReleases}} 120 + <p class="desc"> 121 + <span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span> 122 + {{.TagName}} 123 + {{if not .IsTag}} 124 + <a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 125 + {{end}} 126 + {{TimeSinceUnix .CreatedUnix ctx.Locale}} 127 + </p> 128 + {{end}} 129 + </div> 130 + {{end}} 131 + 132 + {{if gt .Activity.MergedPRCount 0}} 133 + <h4 class="divider divider-text gt-normal-case" id="merged-pull-requests"> 134 + {{svg "octicon-git-pull-request" 16 "gt-mr-3"}} 135 + {{ctx.Locale.Tr "repo.activity.title.prs_merged_by" 136 + (ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount) 137 + (ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount) 138 + }} 139 + </h4> 140 + <div class="list"> 141 + {{range .Activity.MergedPRs}} 142 + <p class="desc"> 143 + <span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span> 144 + #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 145 + {{TimeSinceUnix .MergedUnix ctx.Locale}} 146 + </p> 147 + {{end}} 148 + </div> 149 + {{end}} 150 + 151 + {{if gt .Activity.OpenedPRCount 0}} 152 + <h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests"> 153 + {{svg "octicon-git-branch" 16 "gt-mr-3"}} 154 + {{ctx.Locale.Tr "repo.activity.title.prs_opened_by" 155 + (ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount) 156 + (ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount) 157 + }} 158 + </h4> 159 + <div class="list"> 160 + {{range .Activity.OpenedPRs}} 161 + <p class="desc"> 162 + <span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span> 163 + #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 164 + {{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} 165 + </p> 166 + {{end}} 167 + </div> 168 + {{end}} 169 + 170 + {{if gt .Activity.ClosedIssueCount 0}} 171 + <h4 class="divider divider-text gt-normal-case" id="closed-issues"> 172 + {{svg "octicon-issue-closed" 16 "gt-mr-3"}} 173 + {{ctx.Locale.Tr "repo.activity.title.issues_closed_from" 174 + (ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount) 175 + (ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount) 176 + }} 177 + </h4> 178 + <div class="list"> 179 + {{range .Activity.ClosedIssues}} 180 + <p class="desc"> 181 + <span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span> 182 + #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 183 + {{TimeSinceUnix .ClosedUnix ctx.Locale}} 184 + </p> 185 + {{end}} 186 + </div> 187 + {{end}} 188 + 189 + {{if gt .Activity.OpenedIssueCount 0}} 190 + <h4 class="divider divider-text gt-normal-case" id="new-issues"> 191 + {{svg "octicon-issue-opened" 16 "gt-mr-3"}} 192 + {{ctx.Locale.Tr "repo.activity.title.issues_created_by" 193 + (ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount) 194 + (ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount) 195 + }} 196 + </h4> 197 + <div class="list"> 198 + {{range .Activity.OpenedIssues}} 199 + <p class="desc"> 200 + <span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span> 201 + #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 202 + {{TimeSinceUnix .CreatedUnix ctx.Locale}} 203 + </p> 204 + {{end}} 205 + </div> 206 + {{end}} 207 + 208 + {{if gt .Activity.UnresolvedIssueCount 0}} 209 + <h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}"> 210 + {{svg "octicon-comment-discussion" 16 "gt-mr-3"}} 211 + {{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}} 212 + </h4> 213 + <div class="list"> 214 + {{range .Activity.UnresolvedIssues}} 215 + <p class="desc"> 216 + <span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span> 217 + #{{.Index}} 218 + {{if .IsPull}} 219 + <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 220 + {{else}} 221 + <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> 222 + {{end}} 223 + {{TimeSinceUnix .UpdatedUnix ctx.Locale}} 224 + </p> 225 + {{end}} 226 + </div> 227 + {{end}}
+4
web_src/js/components/.eslintrc.yaml
··· 7 7 - plugin:vue/vue3-recommended 8 8 - plugin:vue-scoped-css/vue3-recommended 9 9 10 + parserOptions: 11 + sourceType: module 12 + ecmaVersion: latest 13 + 10 14 env: 11 15 browser: true 12 16
+443
web_src/js/components/RepoContributors.vue
··· 1 + <script> 2 + import {SvgIcon} from '../svg.js'; 3 + import { 4 + Chart, 5 + Title, 6 + Tooltip, 7 + Legend, 8 + BarElement, 9 + CategoryScale, 10 + LinearScale, 11 + TimeScale, 12 + PointElement, 13 + LineElement, 14 + Filler, 15 + } from 'chart.js'; 16 + import {GET} from '../modules/fetch.js'; 17 + import zoomPlugin from 'chartjs-plugin-zoom'; 18 + import {Line as ChartLine} from 'vue-chartjs'; 19 + import { 20 + startDaysBetween, 21 + firstStartDateAfterDate, 22 + fillEmptyStartDaysWithZeroes, 23 + } from '../utils/time.js'; 24 + import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; 25 + import $ from 'jquery'; 26 + 27 + const {pageData} = window.config; 28 + 29 + const colors = { 30 + text: '--color-text', 31 + border: '--color-secondary-alpha-60', 32 + commits: '--color-primary-alpha-60', 33 + additions: '--color-green', 34 + deletions: '--color-red', 35 + title: '--color-secondary-dark-4', 36 + }; 37 + 38 + const styles = window.getComputedStyle(document.documentElement); 39 + const getColor = (name) => styles.getPropertyValue(name).trim(); 40 + 41 + for (const [key, value] of Object.entries(colors)) { 42 + colors[key] = getColor(value); 43 + } 44 + 45 + const customEventListener = { 46 + id: 'customEventListener', 47 + afterEvent: (chart, args, opts) => { 48 + // event will be replayed from chart.update when reset zoom, 49 + // so we need to check whether args.replay is true to avoid call loops 50 + if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) { 51 + chart.resetZoom(); 52 + opts.instance.updateOtherCharts(args.event, true); 53 + } 54 + } 55 + }; 56 + 57 + Chart.defaults.color = colors.text; 58 + Chart.defaults.borderColor = colors.border; 59 + 60 + Chart.register( 61 + TimeScale, 62 + CategoryScale, 63 + LinearScale, 64 + BarElement, 65 + Title, 66 + Tooltip, 67 + Legend, 68 + PointElement, 69 + LineElement, 70 + Filler, 71 + zoomPlugin, 72 + customEventListener, 73 + ); 74 + 75 + export default { 76 + components: {ChartLine, SvgIcon}, 77 + props: { 78 + locale: { 79 + type: Object, 80 + required: true, 81 + }, 82 + }, 83 + data: () => ({ 84 + isLoading: false, 85 + errorText: '', 86 + totalStats: {}, 87 + sortedContributors: {}, 88 + repoLink: pageData.repoLink || [], 89 + type: pageData.contributionType, 90 + contributorsStats: [], 91 + xAxisStart: null, 92 + xAxisEnd: null, 93 + xAxisMin: null, 94 + xAxisMax: null, 95 + }), 96 + mounted() { 97 + this.fetchGraphData(); 98 + 99 + $('#repo-contributors').dropdown({ 100 + onChange: (val) => { 101 + this.xAxisMin = this.xAxisStart; 102 + this.xAxisMax = this.xAxisEnd; 103 + this.type = val; 104 + this.sortContributors(); 105 + } 106 + }); 107 + }, 108 + methods: { 109 + sortContributors() { 110 + const contributors = this.filterContributorWeeksByDateRange(); 111 + const criteria = `total_${this.type}`; 112 + this.sortedContributors = Object.values(contributors) 113 + .filter((contributor) => contributor[criteria] !== 0) 114 + .sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1) 115 + .slice(0, 100); 116 + }, 117 + 118 + async fetchGraphData() { 119 + this.isLoading = true; 120 + try { 121 + let response; 122 + do { 123 + response = await GET(`${this.repoLink}/activity/contributors/data`); 124 + if (response.status === 202) { 125 + await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying 126 + } 127 + } while (response.status === 202); 128 + if (response.ok) { 129 + const data = await response.json(); 130 + const {total, ...rest} = data; 131 + // below line might be deleted if we are sure go produces map always sorted by keys 132 + total.weeks = Object.fromEntries(Object.entries(total.weeks).sort()); 133 + 134 + const weekValues = Object.values(total.weeks); 135 + this.xAxisStart = weekValues[0].week; 136 + this.xAxisEnd = firstStartDateAfterDate(new Date()); 137 + const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd)); 138 + total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks); 139 + this.xAxisMin = this.xAxisStart; 140 + this.xAxisMax = this.xAxisEnd; 141 + this.contributorsStats = {}; 142 + for (const [email, user] of Object.entries(rest)) { 143 + user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks); 144 + this.contributorsStats[email] = user; 145 + } 146 + this.sortContributors(); 147 + this.totalStats = total; 148 + this.errorText = ''; 149 + } else { 150 + this.errorText = response.statusText; 151 + } 152 + } catch (err) { 153 + this.errorText = err.message; 154 + } finally { 155 + this.isLoading = false; 156 + } 157 + }, 158 + 159 + filterContributorWeeksByDateRange() { 160 + const filteredData = {}; 161 + const data = this.contributorsStats; 162 + for (const key of Object.keys(data)) { 163 + const user = data[key]; 164 + user.total_commits = 0; 165 + user.total_additions = 0; 166 + user.total_deletions = 0; 167 + user.max_contribution_type = 0; 168 + const filteredWeeks = user.weeks.filter((week) => { 169 + const oneWeek = 7 * 24 * 60 * 60 * 1000; 170 + if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) { 171 + user.total_commits += week.commits; 172 + user.total_additions += week.additions; 173 + user.total_deletions += week.deletions; 174 + if (week[this.type] > user.max_contribution_type) { 175 + user.max_contribution_type = week[this.type]; 176 + } 177 + return true; 178 + } 179 + return false; 180 + }); 181 + // this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722 182 + // for details. 183 + user.max_contribution_type += 1; 184 + 185 + filteredData[key] = {...user, weeks: filteredWeeks}; 186 + } 187 + 188 + return filteredData; 189 + }, 190 + 191 + maxMainGraph() { 192 + // This method calculates maximum value for Y value of the main graph. If the number 193 + // of maximum contributions for selected contribution type is 15.955 it is probably 194 + // better to round it up to 20.000.This method is responsible for doing that. 195 + // Normally, chartjs handles this automatically, but it will resize the graph when you 196 + // zoom, pan etc. I think resizing the graph makes it harder to compare things visually. 197 + const maxValue = Math.max( 198 + ...this.totalStats.weeks.map((o) => o[this.type]) 199 + ); 200 + const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); 201 + if (coefficient % 1 === 0) return maxValue; 202 + return (1 - (coefficient % 1)) * 10 ** exp + maxValue; 203 + }, 204 + 205 + maxContributorGraph() { 206 + // Similar to maxMainGraph method this method calculates maximum value for Y value 207 + // for contributors' graph. If I let chartjs do this for me, it will choose different 208 + // maxY value for each contributors' graph which again makes it harder to compare. 209 + const maxValue = Math.max( 210 + ...this.sortedContributors.map((c) => c.max_contribution_type) 211 + ); 212 + const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); 213 + if (coefficient % 1 === 0) return maxValue; 214 + return (1 - (coefficient % 1)) * 10 ** exp + maxValue; 215 + }, 216 + 217 + toGraphData(data) { 218 + return { 219 + datasets: [ 220 + { 221 + data: data.map((i) => ({x: i.week, y: i[this.type]})), 222 + pointRadius: 0, 223 + pointHitRadius: 0, 224 + fill: 'start', 225 + backgroundColor: colors[this.type], 226 + borderWidth: 0, 227 + tension: 0.3, 228 + }, 229 + ], 230 + }; 231 + }, 232 + 233 + updateOtherCharts(event, reset) { 234 + const minVal = event.chart.options.scales.x.min; 235 + const maxVal = event.chart.options.scales.x.max; 236 + if (reset) { 237 + this.xAxisMin = this.xAxisStart; 238 + this.xAxisMax = this.xAxisEnd; 239 + this.sortContributors(); 240 + } else if (minVal) { 241 + this.xAxisMin = minVal; 242 + this.xAxisMax = maxVal; 243 + this.sortContributors(); 244 + } 245 + }, 246 + 247 + getOptions(type) { 248 + return { 249 + responsive: true, 250 + maintainAspectRatio: false, 251 + animation: false, 252 + events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'], 253 + plugins: { 254 + title: { 255 + display: type === 'main', 256 + text: 'drag: zoom, shift+drag: pan, double click: reset zoom', 257 + color: colors.title, 258 + position: 'top', 259 + align: 'center', 260 + }, 261 + customEventListener: { 262 + chartType: type, 263 + instance: this, 264 + }, 265 + legend: { 266 + display: false, 267 + }, 268 + zoom: { 269 + pan: { 270 + enabled: true, 271 + modifierKey: 'shift', 272 + mode: 'x', 273 + threshold: 20, 274 + onPanComplete: this.updateOtherCharts, 275 + }, 276 + limits: { 277 + x: { 278 + // Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits 279 + // to know what each option means 280 + min: 'original', 281 + max: 'original', 282 + 283 + // number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph 284 + minRange: 2 * 7 * 24 * 60 * 60 * 1000, 285 + }, 286 + }, 287 + zoom: { 288 + drag: { 289 + enabled: type === 'main', 290 + }, 291 + pinch: { 292 + enabled: type === 'main', 293 + }, 294 + mode: 'x', 295 + onZoomComplete: this.updateOtherCharts, 296 + }, 297 + }, 298 + }, 299 + scales: { 300 + x: { 301 + min: this.xAxisMin, 302 + max: this.xAxisMax, 303 + type: 'time', 304 + grid: { 305 + display: false, 306 + }, 307 + time: { 308 + minUnit: 'month', 309 + }, 310 + ticks: { 311 + maxRotation: 0, 312 + maxTicksLimit: type === 'main' ? 12 : 6, 313 + }, 314 + }, 315 + y: { 316 + min: 0, 317 + max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(), 318 + ticks: { 319 + maxTicksLimit: type === 'main' ? 6 : 4, 320 + }, 321 + }, 322 + }, 323 + }; 324 + }, 325 + }, 326 + }; 327 + </script> 328 + <template> 329 + <div> 330 + <h2 class="ui header gt-df gt-ac gt-sb"> 331 + <div> 332 + <relative-time 333 + v-if="xAxisMin > 0" 334 + format="datetime" 335 + year="numeric" 336 + month="short" 337 + day="numeric" 338 + weekday="" 339 + :datetime="new Date(xAxisMin)" 340 + > 341 + {{ new Date(xAxisMin) }} 342 + </relative-time> 343 + {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }} 344 + <relative-time 345 + v-if="xAxisMax > 0" 346 + format="datetime" 347 + year="numeric" 348 + month="short" 349 + day="numeric" 350 + weekday="" 351 + :datetime="new Date(xAxisMax)" 352 + > 353 + {{ new Date(xAxisMax) }} 354 + </relative-time> 355 + </div> 356 + <div> 357 + <!-- Contribution type --> 358 + <div class="ui dropdown jump" id="repo-contributors"> 359 + <div class="ui basic compact button"> 360 + <span class="text"> 361 + {{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong> 362 + <svg-icon name="octicon-triangle-down" :size="14"/> 363 + </span> 364 + </div> 365 + <div class="menu"> 366 + <div :class="['item', {'active': type === 'commits'}]"> 367 + {{ locale.contributionType.commits }} 368 + </div> 369 + <div :class="['item', {'active': type === 'additions'}]"> 370 + {{ locale.contributionType.additions }} 371 + </div> 372 + <div :class="['item', {'active': type === 'deletions'}]"> 373 + {{ locale.contributionType.deletions }} 374 + </div> 375 + </div> 376 + </div> 377 + </div> 378 + </h2> 379 + <div class="gt-df ui segment main-graph"> 380 + <div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto"> 381 + <div v-if="isLoading"> 382 + <SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/> 383 + {{ locale.loadingInfo }} 384 + </div> 385 + <div v-else class="text red"> 386 + <SvgIcon name="octicon-x-circle-fill"/> 387 + {{ errorText }} 388 + </div> 389 + </div> 390 + <ChartLine 391 + v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0" 392 + :data="toGraphData(totalStats.weeks)" :options="getOptions('main')" 393 + /> 394 + </div> 395 + <div class="contributor-grid"> 396 + <div 397 + v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table" 398 + v-memo="[sortedContributors, type]" 399 + > 400 + <div class="ui top attached header gt-df gt-f1"> 401 + <b class="ui right">#{{ index + 1 }}</b> 402 + <a :href="contributor.home_link"> 403 + <img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link"> 404 + </a> 405 + <div class="gt-ml-3"> 406 + <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> 407 + <h4 v-else class="contributor-name"> 408 + {{ contributor.name }} 409 + </h4> 410 + <p class="gt-font-12 gt-df gt-gap-2"> 411 + <strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong> 412 + <strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong> 413 + <strong v-if="contributor.total_deletions" class="text red"> 414 + {{ contributor.total_deletions.toLocaleString() }}--</strong> 415 + </p> 416 + </div> 417 + </div> 418 + <div class="ui attached segment"> 419 + <div> 420 + <ChartLine 421 + :data="toGraphData(contributor.weeks)" 422 + :options="getOptions('contributor')" 423 + /> 424 + </div> 425 + </div> 426 + </div> 427 + </div> 428 + </div> 429 + </template> 430 + <style scoped> 431 + .main-graph { 432 + height: 260px; 433 + } 434 + .contributor-grid { 435 + display: grid; 436 + grid-template-columns: repeat(2, 1fr); 437 + gap: 1rem; 438 + } 439 + 440 + .contributor-name { 441 + margin-bottom: 0; 442 + } 443 + </style>
+28
web_src/js/features/contributors.js
··· 1 + import {createApp} from 'vue'; 2 + 3 + export async function initRepoContributors() { 4 + const el = document.getElementById('repo-contributors-chart'); 5 + if (!el) return; 6 + 7 + const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue'); 8 + try { 9 + const View = createApp(RepoContributors, { 10 + locale: { 11 + filterLabel: el.getAttribute('data-locale-filter-label'), 12 + contributionType: { 13 + commits: el.getAttribute('data-locale-contribution-type-commits'), 14 + additions: el.getAttribute('data-locale-contribution-type-additions'), 15 + deletions: el.getAttribute('data-locale-contribution-type-deletions'), 16 + }, 17 + 18 + loadingTitle: el.getAttribute('data-locale-loading-title'), 19 + loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), 20 + loadingInfo: el.getAttribute('data-locale-loading-info'), 21 + } 22 + }); 23 + View.mount(el); 24 + } catch (err) { 25 + console.error('RepoContributors failed to load', err); 26 + el.textContent = el.getAttribute('data-locale-component-failed-to-load'); 27 + } 28 + }
+2
web_src/js/index.js
··· 83 83 import {onDomReady} from './utils/dom.js'; 84 84 import {initRepoIssueList} from './features/repo-issue-list.js'; 85 85 import {initCommonIssueListQuickGoto} from './features/common-issue-list.js'; 86 + import {initRepoContributors} from './features/contributors.js'; 86 87 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js'; 87 88 import {initDirAuto} from './modules/dirauto.js'; 88 89 ··· 172 173 initRepoWikiForm(); 173 174 initRepository(); 174 175 initRepositoryActionView(); 176 + initRepoContributors(); 175 177 176 178 initCommitStatuses(); 177 179 initCaptcha();
+46
web_src/js/utils/time.js
··· 1 + import dayjs from 'dayjs'; 2 + 3 + // Returns an array of millisecond-timestamps of start-of-week days (Sundays) 4 + export function startDaysBetween(startDate, endDate) { 5 + // Ensure the start date is a Sunday 6 + while (startDate.getDay() !== 0) { 7 + startDate.setDate(startDate.getDate() + 1); 8 + } 9 + 10 + const start = dayjs(startDate); 11 + const end = dayjs(endDate); 12 + const startDays = []; 13 + 14 + let current = start; 15 + while (current.isBefore(end)) { 16 + startDays.push(current.valueOf()); 17 + // we are adding 7 * 24 hours instead of 1 week because we don't want 18 + // date library to use local time zone to calculate 1 week from now. 19 + // local time zone is problematic because of daylight saving time (dst) 20 + // used on some countries 21 + current = current.add(7 * 24, 'hour'); 22 + } 23 + 24 + return startDays; 25 + } 26 + 27 + export function firstStartDateAfterDate(inputDate) { 28 + if (!(inputDate instanceof Date)) { 29 + throw new Error('Invalid date'); 30 + } 31 + const dayOfWeek = inputDate.getDay(); 32 + const daysUntilSunday = 7 - dayOfWeek; 33 + const resultDate = new Date(inputDate.getTime()); 34 + resultDate.setDate(resultDate.getDate() + daysUntilSunday); 35 + return resultDate.valueOf(); 36 + } 37 + 38 + export function fillEmptyStartDaysWithZeroes(startDays, data) { 39 + const result = {}; 40 + 41 + for (const startDay of startDays) { 42 + result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0}; 43 + } 44 + 45 + return Object.values(result); 46 + }
+15
web_src/js/utils/time.test.js
··· 1 + import {startDaysBetween} from './time.js'; 2 + 3 + test('startDaysBetween', () => { 4 + expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([ 5 + 1708214400000, 6 + 1708819200000, 7 + 1709424000000, 8 + 1710028800000, 9 + 1710633600000, 10 + 1711238400000, 11 + 1711843200000, 12 + 1712448000000, 13 + 1713052800000, 14 + ]); 15 + });