mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-27 06:03:51 +03:00
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)
This commit is contained in:
parent
1f8ad34e43
commit
e9be8b25ae
18 changed files with 1330 additions and 230 deletions
|
@ -1957,6 +1957,8 @@ wiki.page_name_desc = Enter a name for this Wiki page. Some special names are: '
|
|||
wiki.original_git_entry_tooltip = View original Git file instead of using friendly link.
|
||||
|
||||
activity = Activity
|
||||
activity.navbar.pulse = Pulse
|
||||
activity.navbar.contributors = Contributors
|
||||
activity.period.filter_label = Period:
|
||||
activity.period.daily = 1 day
|
||||
activity.period.halfweekly = 3 days
|
||||
|
@ -2022,6 +2024,16 @@ activity.git_stats_and_deletions = and
|
|||
activity.git_stats_deletion_1 = %d deletion
|
||||
activity.git_stats_deletion_n = %d deletions
|
||||
|
||||
contributors = Contributors
|
||||
contributors.contribution_type.filter_label = Contribution type:
|
||||
contributors.contribution_type.commits = Commits
|
||||
contributors.contribution_type.additions = Additions
|
||||
contributors.contribution_type.deletions = Deletions
|
||||
contributors.loading_title = Loading contributions...
|
||||
contributors.loading_title_failed = Could not load contributions
|
||||
contributors.loading_info = This might take a bit…
|
||||
contributors.component_failed_to_load = An unexpected error happened.
|
||||
|
||||
search = Search
|
||||
search.search_repo = Search repository
|
||||
search.type.tooltip = Search type
|
||||
|
|
67
package-lock.json
generated
67
package-lock.json
generated
|
@ -19,8 +19,12 @@
|
|||
"add-asset-webpack-plugin": "2.0.1",
|
||||
"ansi_up": "6.0.2",
|
||||
"asciinema-player": "3.6.3",
|
||||
"chart.js": "4.3.0",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"clippie": "4.0.6",
|
||||
"css-loader": "6.10.0",
|
||||
"dayjs": "1.11.10",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
"easymde": "2.18.0",
|
||||
"esbuild-loader": "4.0.3",
|
||||
|
@ -47,6 +51,7 @@
|
|||
"uint8-to-base64": "0.2.0",
|
||||
"vue": "3.4.18",
|
||||
"vue-bar-graph": "2.0.0",
|
||||
"vue-chartjs": "5.3.0",
|
||||
"vue-loader": "17.4.2",
|
||||
"vue3-calendar-heatmap": "2.0.5",
|
||||
"webpack": "5.90.1",
|
||||
|
@ -1278,6 +1283,11 @@
|
|||
"jsep": "^0.4.0||^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
|
||||
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
|
||||
},
|
||||
"node_modules/@mcaptcha/core-glue": {
|
||||
"version": "0.1.0-alpha-5",
|
||||
"resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
|
||||
|
@ -3329,6 +3339,40 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz",
|
||||
"integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-dayjs-4": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
|
||||
"integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.1",
|
||||
"dayjs": "^1.9.7"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-zoom": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz",
|
||||
"integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==",
|
||||
"dependencies": {
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
||||
|
@ -5868,9 +5912,17 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.12.5",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.5.tgz",
|
||||
"integrity": "sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ=="
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.2.tgz",
|
||||
"integrity": "sha512-EkYnpG8qHgYBFAwsgsGEqvT1WUidX0tt/ijepx7z8EUJHElykg91RvW1XbkT59T0gZzzszOpjQv7SE41XuIXyQ=="
|
||||
},
|
||||
"node_modules/hammerjs": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
|
||||
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.0.2",
|
||||
|
@ -10934,6 +10986,15 @@
|
|||
"vue": "^3.2.37"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.0.tgz",
|
||||
"integrity": "sha512-8XqX0JU8vFZ+WA2/knz4z3ThClduni2Nm0BMe2u0mXgTfd9pXrmJ07QBI+WAij5P/aPmPMX54HCE1seWL37ZdQ==",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "9.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz",
|
||||
|
|
|
@ -18,8 +18,12 @@
|
|||
"add-asset-webpack-plugin": "2.0.1",
|
||||
"ansi_up": "6.0.2",
|
||||
"asciinema-player": "3.6.3",
|
||||
"chart.js": "4.3.0",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"clippie": "4.0.6",
|
||||
"css-loader": "6.10.0",
|
||||
"dayjs": "1.11.10",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
"easymde": "2.18.0",
|
||||
"esbuild-loader": "4.0.3",
|
||||
|
@ -46,6 +50,7 @@
|
|||
"uint8-to-base64": "0.2.0",
|
||||
"vue": "3.4.18",
|
||||
"vue-bar-graph": "2.0.0",
|
||||
"vue-chartjs": "5.3.0",
|
||||
"vue-loader": "17.4.2",
|
||||
"vue3-calendar-heatmap": "2.0.5",
|
||||
"webpack": "5.90.1",
|
||||
|
|
|
@ -22,6 +22,8 @@ func Activity(ctx *context.Context) {
|
|||
ctx.Data["Title"] = ctx.Tr("repo.activity")
|
||||
ctx.Data["PageIsActivity"] = true
|
||||
|
||||
ctx.Data["PageIsPulse"] = true
|
||||
|
||||
ctx.Data["Period"] = ctx.Params("period")
|
||||
|
||||
timeUntil := time.Now()
|
||||
|
|
44
routers/web/repo/contributors.go
Normal file
44
routers/web/repo/contributors.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
contributors_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
tplContributors base.TplName = "repo/activity"
|
||||
)
|
||||
|
||||
// Contributors render the page to show repository contributors graph
|
||||
func Contributors(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.contributors")
|
||||
|
||||
ctx.Data["PageIsActivity"] = true
|
||||
ctx.Data["PageIsContributors"] = true
|
||||
|
||||
ctx.PageData["contributionType"] = "commits"
|
||||
|
||||
ctx.PageData["repoLink"] = ctx.Repo.RepoLink
|
||||
|
||||
ctx.HTML(http.StatusOK, tplContributors)
|
||||
}
|
||||
|
||||
// ContributorsData renders JSON of contributors along with their weekly commit statistics
|
||||
func ContributorsData(ctx *context.Context) {
|
||||
if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil {
|
||||
if errors.Is(err, contributors_service.ErrAwaitGeneration) {
|
||||
ctx.Status(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetContributorStats", err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusOK, contributorStats)
|
||||
}
|
||||
}
|
|
@ -1431,6 +1431,10 @@ func registerRoutes(m *web.Route) {
|
|||
m.Group("/activity", func() {
|
||||
m.Get("", repo.Activity)
|
||||
m.Get("/{period}", repo.Activity)
|
||||
m.Group("/contributors", func() {
|
||||
m.Get("", repo.Contributors)
|
||||
m.Get("/data", repo.ContributorsData)
|
||||
})
|
||||
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases))
|
||||
|
||||
m.Group("/activity_author_data", func() {
|
||||
|
|
319
services/repository/contributors_graph.go
Normal file
319
services/repository/contributors_graph.go
Normal file
|
@ -0,0 +1,319 @@
|
|||
// 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{}{}
|
||||
}
|
||||
}
|
87
services/repository/contributors_graph_test.go
Normal file
87
services/repository/contributors_graph_test.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
|
||||
"gitea.com/go-chi/cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_ContributorsGraph(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
|
||||
mockCache, err := cache.NewCacher(cache.Options{
|
||||
Adapter: "memory",
|
||||
Interval: 24 * 60,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
generateContributorStats(nil, mockCache, "key", repo, "404ref")
|
||||
err, isErr := mockCache.Get("key").(error)
|
||||
assert.True(t, isErr)
|
||||
assert.ErrorAs(t, err, &git.ErrNotExist{})
|
||||
|
||||
generateContributorStats(nil, mockCache, "key2", repo, "master")
|
||||
data, isData := mockCache.Get("key2").(map[string]*ContributorData)
|
||||
assert.True(t, isData)
|
||||
var keys []string
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
assert.EqualValues(t, []string{
|
||||
"ethantkoenig@gmail.com",
|
||||
"jimmy.praet@telenet.be",
|
||||
"jon@allspice.io",
|
||||
"total", // generated summary
|
||||
}, keys)
|
||||
|
||||
assert.EqualValues(t, &ContributorData{
|
||||
Name: "Ethan Koenig",
|
||||
AvatarLink: "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon",
|
||||
TotalCommits: 1,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["ethantkoenig@gmail.com"])
|
||||
assert.EqualValues(t, &ContributorData{
|
||||
Name: "Total",
|
||||
AvatarLink: "",
|
||||
TotalCommits: 3,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800)
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1607817600000: {
|
||||
Week: 1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500)
|
||||
Additions: 10,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1624752000000: {
|
||||
Week: 1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200)
|
||||
Additions: 2,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["total"])
|
||||
}
|
|
@ -1,235 +1,15 @@
|
|||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository commits">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<h2 class="ui header activity-header">
|
||||
<span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span>
|
||||
<!-- Period -->
|
||||
<div class="ui floating dropdown jump filter">
|
||||
<div class="ui basic compact button">
|
||||
{{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a>
|
||||
<a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a>
|
||||
<a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a>
|
||||
<a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a>
|
||||
<a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a>
|
||||
<a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a>
|
||||
<a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="divider"></div>
|
||||
|
||||
{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
|
||||
<h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4>
|
||||
<div class="ui attached segment two column grid">
|
||||
{{if .Permission.CanRead $.UnitTypePullRequests}}
|
||||
<div class="column">
|
||||
{{if gt .Activity.ActivePRCount 0}}
|
||||
<div class="stats-table">
|
||||
<a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a>
|
||||
<a href="#proposed-pull-requests" class="table-cell tiny background green"></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stats-table">
|
||||
<a class="table-cell tiny background light grey"></a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Permission.CanRead $.UnitTypeIssues}}
|
||||
<div class="column">
|
||||
{{if gt .Activity.ActiveIssueCount 0}}
|
||||
<div class="stats-table">
|
||||
<a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a>
|
||||
<a href="#new-issues" class="table-cell tiny background green"></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stats-table">
|
||||
<a class="table-cell tiny background light grey"></a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="ui container flex-container">
|
||||
<div class="flex-container-nav">
|
||||
{{template "repo/navbar" .}}
|
||||
</div>
|
||||
<div class="ui attached segment horizontal segments">
|
||||
{{if .Permission.CanRead $.UnitTypePullRequests}}
|
||||
<a href="#merged-pull-requests" class="ui attached segment text center">
|
||||
<span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
|
||||
</a>
|
||||
<a href="#proposed-pull-requests" class="ui attached segment text center">
|
||||
<span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Permission.CanRead $.UnitTypeIssues}}
|
||||
<a href="#closed-issues" class="ui attached segment text center">
|
||||
<span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
|
||||
</a>
|
||||
<a href="#new-issues" class="ui attached segment text center">
|
||||
<span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="flex-container-main">
|
||||
{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
|
||||
{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead $.UnitTypeCode}}
|
||||
{{if eq .Activity.Code.CommitCountInAllBranches 0}}
|
||||
<div class="ui center aligned segment">
|
||||
<h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if gt .Activity.Code.CommitCountInAllBranches 0}}
|
||||
<div class="ui attached segment horizontal segments">
|
||||
<div class="ui attached segment text">
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong>
|
||||
{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong>
|
||||
{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}}
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_additions"}}
|
||||
<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>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
|
||||
<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>.
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div id="repo-activity-top-authors-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.PublishedReleaseCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="published-releases">
|
||||
{{svg "octicon-tag" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
|
||||
(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
|
||||
(ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.PublishedReleases}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span>
|
||||
{{.TagName}}
|
||||
{{if not .IsTag}}
|
||||
<a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{end}}
|
||||
{{TimeSinceUnix .CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.MergedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
|
||||
{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
|
||||
(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
|
||||
(ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.MergedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .MergedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
|
||||
{{svg "octicon-git-branch" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
|
||||
(ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.OpenedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.ClosedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="closed-issues">
|
||||
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
|
||||
(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
|
||||
(ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.ClosedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .ClosedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="new-issues">
|
||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
|
||||
(ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.OpenedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.UnresolvedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
|
||||
{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.UnresolvedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
|
||||
#{{.Index}}
|
||||
{{if .IsPull}}
|
||||
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{else}}
|
||||
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{end}}
|
||||
{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
||||
|
|
13
templates/repo/contributors.tmpl
Normal file
13
templates/repo/contributors.tmpl
Normal file
|
@ -0,0 +1,13 @@
|
|||
{{if .Permission.CanRead $.UnitTypeCode}}
|
||||
<div id="repo-contributors-chart"
|
||||
data-locale-filter-label="{{ctx.Locale.Tr "repo.contributors.contribution_type.filter_label"}}"
|
||||
data-locale-contribution-type-commits="{{ctx.Locale.Tr "repo.contributors.contribution_type.commits"}}"
|
||||
data-locale-contribution-type-additions="{{ctx.Locale.Tr "repo.contributors.contribution_type.additions"}}"
|
||||
data-locale-contribution-type-deletions="{{ctx.Locale.Tr "repo.contributors.contribution_type.deletions"}}"
|
||||
data-locale-loading-title="{{ctx.Locale.Tr "repo.contributors.loading_title"}}"
|
||||
data-locale-loading-title-failed="{{ctx.Locale.Tr "repo.contributors.loading_title_failed"}}"
|
||||
data-locale-loading-info="{{ctx.Locale.Tr "repo.contributors.loading_info"}}"
|
||||
data-locale-component-failed-to-load="{{ctx.Locale.Tr "repo.contributors.component_failed_to_load"}}"
|
||||
>
|
||||
</div>
|
||||
{{end}}
|
8
templates/repo/navbar.tmpl
Normal file
8
templates/repo/navbar.tmpl
Normal file
|
@ -0,0 +1,8 @@
|
|||
<div class="ui fluid vertical menu">
|
||||
<a class="{{if .PageIsPulse}}active {{end}}item" href="{{.RepoLink}}/activity">
|
||||
{{ctx.Locale.Tr "repo.activity.navbar.pulse"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsContributors}}active {{end}}item" href="{{.RepoLink}}/activity/contributors">
|
||||
{{ctx.Locale.Tr "repo.activity.navbar.contributors"}}
|
||||
</a>
|
||||
</div>
|
227
templates/repo/pulse.tmpl
Normal file
227
templates/repo/pulse.tmpl
Normal file
|
@ -0,0 +1,227 @@
|
|||
<h2 class="ui header activity-header">
|
||||
<span>{{DateTime "long" .DateFrom}} - {{DateTime "long" .DateUntil}}</span>
|
||||
<!-- Period -->
|
||||
<div class="ui floating dropdown jump filter">
|
||||
<div class="ui basic compact button">
|
||||
{{ctx.Locale.Tr "repo.activity.period.filter_label"}} <strong>{{.PeriodText}}</strong>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
</div>
|
||||
<div class="menu">
|
||||
<a class="{{if eq .Period "daily"}}active {{end}}item" href="{{$.RepoLink}}/activity/daily">{{ctx.Locale.Tr "repo.activity.period.daily"}}</a>
|
||||
<a class="{{if eq .Period "halfweekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/halfweekly">{{ctx.Locale.Tr "repo.activity.period.halfweekly"}}</a>
|
||||
<a class="{{if eq .Period "weekly"}}active {{end}}item" href="{{$.RepoLink}}/activity/weekly">{{ctx.Locale.Tr "repo.activity.period.weekly"}}</a>
|
||||
<a class="{{if eq .Period "monthly"}}active {{end}}item" href="{{$.RepoLink}}/activity/monthly">{{ctx.Locale.Tr "repo.activity.period.monthly"}}</a>
|
||||
<a class="{{if eq .Period "quarterly"}}active {{end}}item" href="{{$.RepoLink}}/activity/quarterly">{{ctx.Locale.Tr "repo.activity.period.quarterly"}}</a>
|
||||
<a class="{{if eq .Period "semiyearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/semiyearly">{{ctx.Locale.Tr "repo.activity.period.semiyearly"}}</a>
|
||||
<a class="{{if eq .Period "yearly"}}active {{end}}item" href="{{$.RepoLink}}/activity/yearly">{{ctx.Locale.Tr "repo.activity.period.yearly"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
|
||||
<h4 class="ui top attached header">{{ctx.Locale.Tr "repo.activity.overview"}}</h4>
|
||||
<div class="ui attached segment two column grid">
|
||||
{{if .Permission.CanRead $.UnitTypePullRequests}}
|
||||
<div class="column">
|
||||
{{if gt .Activity.ActivePRCount 0}}
|
||||
<div class="stats-table">
|
||||
<a href="#merged-pull-requests" class="table-cell tiny background purple" style="width: {{.Activity.MergedPRPerc}}{{if ne .Activity.MergedPRPerc 0}}%{{end}}"></a>
|
||||
<a href="#proposed-pull-requests" class="table-cell tiny background green"></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stats-table">
|
||||
<a class="table-cell tiny background light grey"></a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Permission.CanRead $.UnitTypeIssues}}
|
||||
<div class="column">
|
||||
{{if gt .Activity.ActiveIssueCount 0}}
|
||||
<div class="stats-table">
|
||||
<a href="#closed-issues" class="table-cell tiny background red" style="width: {{.Activity.ClosedIssuePerc}}{{if ne .Activity.ClosedIssuePerc 0}}%{{end}}"></a>
|
||||
<a href="#new-issues" class="table-cell tiny background green"></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stats-table">
|
||||
<a class="table-cell tiny background light grey"></a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="ui attached segment horizontal segments">
|
||||
{{if .Permission.CanRead $.UnitTypePullRequests}}
|
||||
<a href="#merged-pull-requests" class="ui attached segment text center">
|
||||
<span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
|
||||
</a>
|
||||
<a href="#proposed-pull-requests" class="ui attached segment text center">
|
||||
<span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Permission.CanRead $.UnitTypeIssues}}
|
||||
<a href="#closed-issues" class="ui attached segment text center">
|
||||
<span class="text red">{{svg "octicon-issue-closed"}}</span> <strong>{{.Activity.ClosedIssueCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
|
||||
</a>
|
||||
<a href="#new-issues" class="ui attached segment text center">
|
||||
<span class="text green">{{svg "octicon-issue-opened"}}</span> <strong>{{.Activity.OpenedIssueCount}}</strong><br>
|
||||
{{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead $.UnitTypeCode}}
|
||||
{{if eq .Activity.Code.CommitCountInAllBranches 0}}
|
||||
<div class="ui center aligned segment">
|
||||
<h4 class="ui header">{{ctx.Locale.Tr "repo.activity.no_git_activity"}}</h4>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if gt .Activity.Code.CommitCountInAllBranches 0}}
|
||||
<div class="ui attached segment horizontal segments">
|
||||
<div class="ui attached segment text">
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}</strong>
|
||||
{{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}</strong>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}</strong>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
|
||||
<strong>{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}</strong>
|
||||
{{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_files_changed_1" "repo.activity.git_stats_files_changed_n"}}
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_additions"}}
|
||||
<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>
|
||||
{{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
|
||||
<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>.
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div id="repo-activity-top-authors-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.PublishedReleaseCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="published-releases">
|
||||
{{svg "octicon-tag" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
|
||||
(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
|
||||
(ctx.Locale.TrN .Activity.PublishedReleaseAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.PublishedReleaseAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.PublishedReleases}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.published_release_label"}}</span>
|
||||
{{.TagName}}
|
||||
{{if not .IsTag}}
|
||||
<a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{end}}
|
||||
{{TimeSinceUnix .CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.MergedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
|
||||
{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
|
||||
(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
|
||||
(ctx.Locale.TrN .Activity.MergedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.MergedPRAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.MergedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .MergedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
|
||||
{{svg "octicon-git-branch" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
|
||||
(ctx.Locale.TrN .Activity.OpenedPRAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedPRAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.OpenedPRs}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.ClosedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="closed-issues">
|
||||
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
|
||||
(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
|
||||
(ctx.Locale.TrN .Activity.ClosedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.ClosedIssueAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.ClosedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .ClosedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="new-issues">
|
||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
|
||||
(ctx.Locale.TrN .Activity.OpenedIssueAuthorCount "repo.activity.title.user_1" "repo.activity.title.user_n" .Activity.OpenedIssueAuthorCount)
|
||||
}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.OpenedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
|
||||
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{TimeSinceUnix .CreatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Activity.UnresolvedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
|
||||
{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
|
||||
</h4>
|
||||
<div class="list">
|
||||
{{range .Activity.UnresolvedIssues}}
|
||||
<p class="desc">
|
||||
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
|
||||
#{{.Index}}
|
||||
{{if .IsPull}}
|
||||
<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{else}}
|
||||
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||
{{end}}
|
||||
{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
|
@ -7,6 +7,10 @@ extends:
|
|||
- plugin:vue/vue3-recommended
|
||||
- plugin:vue-scoped-css/vue3-recommended
|
||||
|
||||
parserOptions:
|
||||
sourceType: module
|
||||
ecmaVersion: latest
|
||||
|
||||
env:
|
||||
browser: true
|
||||
|
||||
|
|
443
web_src/js/components/RepoContributors.vue
Normal file
443
web_src/js/components/RepoContributors.vue
Normal file
|
@ -0,0 +1,443 @@
|
|||
<script>
|
||||
import {SvgIcon} from '../svg.js';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.js';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import $ from 'jquery';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
const colors = {
|
||||
text: '--color-text',
|
||||
border: '--color-secondary-alpha-60',
|
||||
commits: '--color-primary-alpha-60',
|
||||
additions: '--color-green',
|
||||
deletions: '--color-red',
|
||||
title: '--color-secondary-dark-4',
|
||||
};
|
||||
|
||||
const styles = window.getComputedStyle(document.documentElement);
|
||||
const getColor = (name) => styles.getPropertyValue(name).trim();
|
||||
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
colors[key] = getColor(value);
|
||||
}
|
||||
|
||||
const customEventListener = {
|
||||
id: 'customEventListener',
|
||||
afterEvent: (chart, args, opts) => {
|
||||
// event will be replayed from chart.update when reset zoom,
|
||||
// so we need to check whether args.replay is true to avoid call loops
|
||||
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
||||
chart.resetZoom();
|
||||
opts.instance.updateOtherCharts(args.event, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Chart.defaults.color = colors.text;
|
||||
Chart.defaults.borderColor = colors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
zoomPlugin,
|
||||
customEventListener,
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {ChartLine, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
isLoading: false,
|
||||
errorText: '',
|
||||
totalStats: {},
|
||||
sortedContributors: {},
|
||||
repoLink: pageData.repoLink || [],
|
||||
type: pageData.contributionType,
|
||||
contributorsStats: [],
|
||||
xAxisStart: null,
|
||||
xAxisEnd: null,
|
||||
xAxisMin: null,
|
||||
xAxisMax: null,
|
||||
}),
|
||||
mounted() {
|
||||
this.fetchGraphData();
|
||||
|
||||
$('#repo-contributors').dropdown({
|
||||
onChange: (val) => {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.type = val;
|
||||
this.sortContributors();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
sortContributors() {
|
||||
const contributors = this.filterContributorWeeksByDateRange();
|
||||
const criteria = `total_${this.type}`;
|
||||
this.sortedContributors = Object.values(contributors)
|
||||
.filter((contributor) => contributor[criteria] !== 0)
|
||||
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
|
||||
.slice(0, 100);
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
let response;
|
||||
do {
|
||||
response = await GET(`${this.repoLink}/activity/contributors/data`);
|
||||
if (response.status === 202) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying
|
||||
}
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const {total, ...rest} = data;
|
||||
// below line might be deleted if we are sure go produces map always sorted by keys
|
||||
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
||||
|
||||
const weekValues = Object.values(total.weeks);
|
||||
this.xAxisStart = weekValues[0].week;
|
||||
this.xAxisEnd = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
|
||||
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.contributorsStats = {};
|
||||
for (const [email, user] of Object.entries(rest)) {
|
||||
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
|
||||
this.contributorsStats[email] = user;
|
||||
}
|
||||
this.sortContributors();
|
||||
this.totalStats = total;
|
||||
this.errorText = '';
|
||||
} else {
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterContributorWeeksByDateRange() {
|
||||
const filteredData = {};
|
||||
const data = this.contributorsStats;
|
||||
for (const key of Object.keys(data)) {
|
||||
const user = data[key];
|
||||
user.total_commits = 0;
|
||||
user.total_additions = 0;
|
||||
user.total_deletions = 0;
|
||||
user.max_contribution_type = 0;
|
||||
const filteredWeeks = user.weeks.filter((week) => {
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
|
||||
user.total_commits += week.commits;
|
||||
user.total_additions += week.additions;
|
||||
user.total_deletions += week.deletions;
|
||||
if (week[this.type] > user.max_contribution_type) {
|
||||
user.max_contribution_type = week[this.type];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
||||
// for details.
|
||||
user.max_contribution_type += 1;
|
||||
|
||||
filteredData[key] = {...user, weeks: filteredWeeks};
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
maxMainGraph() {
|
||||
// This method calculates maximum value for Y value of the main graph. If the number
|
||||
// of maximum contributions for selected contribution type is 15.955 it is probably
|
||||
// better to round it up to 20.000.This method is responsible for doing that.
|
||||
// Normally, chartjs handles this automatically, but it will resize the graph when you
|
||||
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
|
||||
const maxValue = Math.max(
|
||||
...this.totalStats.weeks.map((o) => o[this.type])
|
||||
);
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
if (coefficient % 1 === 0) return maxValue;
|
||||
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||||
},
|
||||
|
||||
maxContributorGraph() {
|
||||
// Similar to maxMainGraph method this method calculates maximum value for Y value
|
||||
// for contributors' graph. If I let chartjs do this for me, it will choose different
|
||||
// maxY value for each contributors' graph which again makes it harder to compare.
|
||||
const maxValue = Math.max(
|
||||
...this.sortedContributors.map((c) => c.max_contribution_type)
|
||||
);
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
if (coefficient % 1 === 0) return maxValue;
|
||||
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
|
||||
},
|
||||
|
||||
toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i[this.type]})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: 'start',
|
||||
backgroundColor: colors[this.type],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
updateOtherCharts(event, reset) {
|
||||
const minVal = event.chart.options.scales.x.min;
|
||||
const maxVal = event.chart.options.scales.x.max;
|
||||
if (reset) {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.sortContributors();
|
||||
} else if (minVal) {
|
||||
this.xAxisMin = minVal;
|
||||
this.xAxisMax = maxVal;
|
||||
this.sortContributors();
|
||||
}
|
||||
},
|
||||
|
||||
getOptions(type) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||||
plugins: {
|
||||
title: {
|
||||
display: type === 'main',
|
||||
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
|
||||
color: colors.title,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
customEventListener: {
|
||||
chartType: type,
|
||||
instance: this,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'shift',
|
||||
mode: 'x',
|
||||
threshold: 20,
|
||||
onPanComplete: this.updateOtherCharts,
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
|
||||
// to know what each option means
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
|
||||
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
|
||||
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
pinch: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: this.updateOtherCharts,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: this.xAxisMin,
|
||||
max: this.xAxisMax,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: type === 'main' ? 12 : 6,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
|
||||
ticks: {
|
||||
maxTicksLimit: type === 'main' ? 6 : 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="ui header gt-df gt-ac gt-sb">
|
||||
<div>
|
||||
<relative-time
|
||||
v-if="xAxisMin > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMin)"
|
||||
>
|
||||
{{ new Date(xAxisMin) }}
|
||||
</relative-time>
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
|
||||
<relative-time
|
||||
v-if="xAxisMax > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMax)"
|
||||
>
|
||||
{{ new Date(xAxisMax) }}
|
||||
</relative-time>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Contribution type -->
|
||||
<div class="ui dropdown jump" id="repo-contributors">
|
||||
<div class="ui basic compact button">
|
||||
<span class="text">
|
||||
{{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong>
|
||||
<svg-icon name="octicon-triangle-down" :size="14"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div :class="['item', {'active': type === 'commits'}]">
|
||||
{{ locale.contributionType.commits }}
|
||||
</div>
|
||||
<div :class="['item', {'active': type === 'additions'}]">
|
||||
{{ locale.contributionType.additions }}
|
||||
</div>
|
||||
<div :class="['item', {'active': type === 'deletions'}]">
|
||||
{{ locale.contributionType.deletions }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="gt-df ui segment main-graph">
|
||||
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="text red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<ChartLine
|
||||
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
|
||||
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
|
||||
/>
|
||||
</div>
|
||||
<div class="contributor-grid">
|
||||
<div
|
||||
v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table"
|
||||
v-memo="[sortedContributors, type]"
|
||||
>
|
||||
<div class="ui top attached header gt-df gt-f1">
|
||||
<b class="ui right">#{{ index + 1 }}</b>
|
||||
<a :href="contributor.home_link">
|
||||
<img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
|
||||
</a>
|
||||
<div class="gt-ml-3">
|
||||
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
|
||||
<h4 v-else class="contributor-name">
|
||||
{{ contributor.name }}
|
||||
</h4>
|
||||
<p class="gt-font-12 gt-df gt-gap-2">
|
||||
<strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
|
||||
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||||
<strong v-if="contributor.total_deletions" class="text red">
|
||||
{{ contributor.total_deletions.toLocaleString() }}--</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div>
|
||||
<ChartLine
|
||||
:data="toGraphData(contributor.weeks)"
|
||||
:options="getOptions('contributor')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 260px;
|
||||
}
|
||||
.contributor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contributor-name {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
28
web_src/js/features/contributors.js
Normal file
28
web_src/js/features/contributors.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import {createApp} from 'vue';
|
||||
|
||||
export async function initRepoContributors() {
|
||||
const el = document.getElementById('repo-contributors-chart');
|
||||
if (!el) return;
|
||||
|
||||
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
|
||||
try {
|
||||
const View = createApp(RepoContributors, {
|
||||
locale: {
|
||||
filterLabel: el.getAttribute('data-locale-filter-label'),
|
||||
contributionType: {
|
||||
commits: el.getAttribute('data-locale-contribution-type-commits'),
|
||||
additions: el.getAttribute('data-locale-contribution-type-additions'),
|
||||
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
|
||||
},
|
||||
|
||||
loadingTitle: el.getAttribute('data-locale-loading-title'),
|
||||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
|
||||
loadingInfo: el.getAttribute('data-locale-loading-info'),
|
||||
}
|
||||
});
|
||||
View.mount(el);
|
||||
} catch (err) {
|
||||
console.error('RepoContributors failed to load', err);
|
||||
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
|
||||
}
|
||||
}
|
|
@ -83,6 +83,7 @@ import {initGiteaFomantic} from './modules/fomantic.js';
|
|||
import {onDomReady} from './utils/dom.js';
|
||||
import {initRepoIssueList} from './features/repo-issue-list.js';
|
||||
import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
|
||||
import {initRepoContributors} from './features/contributors.js';
|
||||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
|
||||
import {initDirAuto} from './modules/dirauto.js';
|
||||
|
||||
|
@ -172,6 +173,7 @@ onDomReady(() => {
|
|||
initRepoWikiForm();
|
||||
initRepository();
|
||||
initRepositoryActionView();
|
||||
initRepoContributors();
|
||||
|
||||
initCommitStatuses();
|
||||
initCaptcha();
|
||||
|
|
46
web_src/js/utils/time.js
Normal file
46
web_src/js/utils/time.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import dayjs from 'dayjs';
|
||||
|
||||
// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
export function startDaysBetween(startDate, endDate) {
|
||||
// Ensure the start date is a Sunday
|
||||
while (startDate.getDay() !== 0) {
|
||||
startDate.setDate(startDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
const startDays = [];
|
||||
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
startDays.push(current.valueOf());
|
||||
// we are adding 7 * 24 hours instead of 1 week because we don't want
|
||||
// date library to use local time zone to calculate 1 week from now.
|
||||
// local time zone is problematic because of daylight saving time (dst)
|
||||
// used on some countries
|
||||
current = current.add(7 * 24, 'hour');
|
||||
}
|
||||
|
||||
return startDays;
|
||||
}
|
||||
|
||||
export function firstStartDateAfterDate(inputDate) {
|
||||
if (!(inputDate instanceof Date)) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
const dayOfWeek = inputDate.getDay();
|
||||
const daysUntilSunday = 7 - dayOfWeek;
|
||||
const resultDate = new Date(inputDate.getTime());
|
||||
resultDate.setDate(resultDate.getDate() + daysUntilSunday);
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays, data) {
|
||||
const result = {};
|
||||
|
||||
for (const startDay of startDays) {
|
||||
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
|
||||
}
|
||||
|
||||
return Object.values(result);
|
||||
}
|
15
web_src/js/utils/time.test.js
Normal file
15
web_src/js/utils/time.test.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {startDaysBetween} from './time.js';
|
||||
|
||||
test('startDaysBetween', () => {
|
||||
expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
|
||||
1708214400000,
|
||||
1708819200000,
|
||||
1709424000000,
|
||||
1710028800000,
|
||||
1710633600000,
|
||||
1711238400000,
|
||||
1711843200000,
|
||||
1712448000000,
|
||||
1713052800000,
|
||||
]);
|
||||
});
|
Loading…
Reference in a new issue