diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 96345f51f..5f34bc4c1 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1912,6 +1912,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
@@ -1977,6 +1979,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
diff --git a/package-lock.json b/package-lock.json
index 62bf36e7b..764ae51f9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 46dfdd105..dbb57b162 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go
index 3d030edac..af99c4ed9 100644
--- a/routers/web/repo/activity.go
+++ b/routers/web/repo/activity.go
@@ -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()
diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go
new file mode 100644
index 000000000..f7dedc0b3
--- /dev/null
+++ b/routers/web/repo/contributors.go
@@ -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)
+ }
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 7aa9bb079..a6288caaf 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1392,6 +1392,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() {
diff --git a/services/repository/contributors_graph.go b/services/repository/contributors_graph.go
new file mode 100644
index 000000000..8421df8e3
--- /dev/null
+++ b/services/repository/contributors_graph.go
@@ -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{}{}
+ }
+}
diff --git a/services/repository/contributors_graph_test.go b/services/repository/contributors_graph_test.go
new file mode 100644
index 000000000..3801a5eee
--- /dev/null
+++ b/services/repository/contributors_graph_test.go
@@ -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"])
+}
diff --git a/templates/repo/activity.tmpl b/templates/repo/activity.tmpl
index 3149f2067..960083d2f 100644
--- a/templates/repo/activity.tmpl
+++ b/templates/repo/activity.tmpl
@@ -1,235 +1,15 @@
{{template "base/head" .}}
{{template "repo/header" .}}
-
-
-
-
- {{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
-
-
- {{if .Permission.CanRead $.UnitTypePullRequests}}
-
- {{if gt .Activity.ActivePRCount 0}}
-
- {{else}}
-
- {{end}}
- {{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
-
- {{end}}
- {{if .Permission.CanRead $.UnitTypeIssues}}
-
- {{if gt .Activity.ActiveIssueCount 0}}
-
- {{else}}
-
- {{end}}
- {{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
-
- {{end}}
+
+
+ {{template "repo/navbar" .}}
-
- {{if .Permission.CanRead $.UnitTypePullRequests}}
-
- {{svg "octicon-git-pull-request"}} {{.Activity.MergedPRCount}}
- {{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
-
-
- {{svg "octicon-git-branch"}} {{.Activity.OpenedPRCount}}
- {{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
-
- {{end}}
- {{if .Permission.CanRead $.UnitTypeIssues}}
-
- {{svg "octicon-issue-closed"}} {{.Activity.ClosedIssueCount}}
- {{ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.closed_issues_count_1" "repo.activity.closed_issues_count_n"}}
-
-
- {{svg "octicon-issue-opened"}} {{.Activity.OpenedIssueCount}}
- {{ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.new_issues_count_1" "repo.activity.new_issues_count_n"}}
-
- {{end}}
+
+ {{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
+ {{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
- {{end}}
-
- {{if .Permission.CanRead $.UnitTypeCode}}
- {{if eq .Activity.Code.CommitCountInAllBranches 0}}
-
-
-
- {{end}}
- {{if gt .Activity.Code.CommitCountInAllBranches 0}}
-
-
- {{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
- {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}
- {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
- {{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}
- {{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
- {{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}
- {{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
- {{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
- {{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}
- {{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"}}
- {{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}
- {{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
- {{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}.
-
-
-
- {{end}}
- {{end}}
-
- {{if gt .Activity.PublishedReleaseCount 0}}
-
- {{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)
- }}
-
-
- {{end}}
-
- {{if gt .Activity.MergedPRCount 0}}
-
- {{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)
- }}
-
-
- {{end}}
-
- {{if gt .Activity.OpenedPRCount 0}}
-
- {{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)
- }}
-
-
- {{end}}
-
- {{if gt .Activity.ClosedIssueCount 0}}
-
- {{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)
- }}
-
-
- {{end}}
-
- {{if gt .Activity.OpenedIssueCount 0}}
-
- {{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)
- }}
-
-
- {{end}}
-
- {{if gt .Activity.UnresolvedIssueCount 0}}
-
- {{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}}
-
-
- {{end}}
{{template "base/footer" .}}
+
diff --git a/templates/repo/contributors.tmpl b/templates/repo/contributors.tmpl
new file mode 100644
index 000000000..49a251c1f
--- /dev/null
+++ b/templates/repo/contributors.tmpl
@@ -0,0 +1,13 @@
+{{if .Permission.CanRead $.UnitTypeCode}}
+
+
+{{end}}
diff --git a/templates/repo/navbar.tmpl b/templates/repo/navbar.tmpl
new file mode 100644
index 000000000..a9042ee30
--- /dev/null
+++ b/templates/repo/navbar.tmpl
@@ -0,0 +1,8 @@
+
diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl
new file mode 100644
index 000000000..ccd7ebf6b
--- /dev/null
+++ b/templates/repo/pulse.tmpl
@@ -0,0 +1,227 @@
+
+
+{{if (or (.Permission.CanRead $.UnitTypeIssues) (.Permission.CanRead $.UnitTypePullRequests))}}
+
+
+ {{if .Permission.CanRead $.UnitTypePullRequests}}
+
+ {{if gt .Activity.ActivePRCount 0}}
+
+ {{else}}
+
+ {{end}}
+ {{ctx.Locale.TrN .Activity.ActivePRCount "repo.activity.active_prs_count_1" "repo.activity.active_prs_count_n" .Activity.ActivePRCount | Safe}}
+
+ {{end}}
+ {{if .Permission.CanRead $.UnitTypeIssues}}
+
+ {{if gt .Activity.ActiveIssueCount 0}}
+
+ {{else}}
+
+ {{end}}
+ {{ctx.Locale.TrN .Activity.ActiveIssueCount "repo.activity.active_issues_count_1" "repo.activity.active_issues_count_n" .Activity.ActiveIssueCount | Safe}}
+
+ {{end}}
+
+
+{{end}}
+
+{{if .Permission.CanRead $.UnitTypeCode}}
+ {{if eq .Activity.Code.CommitCountInAllBranches 0}}
+
+
+
+ {{end}}
+ {{if gt .Activity.Code.CommitCountInAllBranches 0}}
+
+
+ {{ctx.Locale.Tr "repo.activity.git_stats_exclude_merges"}}
+ {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n" .Activity.Code.AuthorCount}}
+ {{ctx.Locale.TrN .Activity.Code.AuthorCount "repo.activity.git_stats_pushed_1" "repo.activity.git_stats_pushed_n"}}
+ {{ctx.Locale.TrN .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCount}}
+ {{ctx.Locale.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch}}
+ {{ctx.Locale.TrN .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n" .Activity.Code.CommitCountInAllBranches}}
+ {{ctx.Locale.Tr "repo.activity.git_stats_push_to_all_branches"}}
+ {{ctx.Locale.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch}}
+ {{ctx.Locale.TrN .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n" .Activity.Code.ChangedFiles}}
+ {{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"}}
+ {{ctx.Locale.TrN .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n" .Activity.Code.Additions}}
+ {{ctx.Locale.Tr "repo.activity.git_stats_and_deletions"}}
+ {{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}.
+
+
+
+ {{end}}
+{{end}}
+
+{{if gt .Activity.PublishedReleaseCount 0}}
+
+ {{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)
+ }}
+
+
+{{end}}
+
+{{if gt .Activity.MergedPRCount 0}}
+
+ {{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)
+ }}
+
+
+{{end}}
+
+{{if gt .Activity.OpenedPRCount 0}}
+
+ {{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)
+ }}
+
+
+{{end}}
+
+{{if gt .Activity.ClosedIssueCount 0}}
+
+ {{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)
+ }}
+
+
+{{end}}
+
+{{if gt .Activity.OpenedIssueCount 0}}
+
+ {{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)
+ }}
+
+
+{{end}}
+
+{{if gt .Activity.UnresolvedIssueCount 0}}
+
+ {{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}}
+
+
+{{end}}
diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml
index 0cab470f6..0d233442b 100644
--- a/web_src/js/components/.eslintrc.yaml
+++ b/web_src/js/components/.eslintrc.yaml
@@ -7,6 +7,10 @@ extends:
- plugin:vue/vue3-recommended
- plugin:vue-scoped-css/vue3-recommended
+parserOptions:
+ sourceType: module
+ ecmaVersion: latest
+
env:
browser: true
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
new file mode 100644
index 000000000..fa1545b3d
--- /dev/null
+++ b/web_src/js/components/RepoContributors.vue
@@ -0,0 +1,443 @@
+
+
+
+
+
+
+
+
+ {{ locale.loadingInfo }}
+
+
+
+ {{ errorText }}
+
+
+
+
+
+
+
+
diff --git a/web_src/js/features/contributors.js b/web_src/js/features/contributors.js
new file mode 100644
index 000000000..66185ac31
--- /dev/null
+++ b/web_src/js/features/contributors.js
@@ -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');
+ }
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 471361850..078f9fc9d 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -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();
diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js
new file mode 100644
index 000000000..3284e893e
--- /dev/null
+++ b/web_src/js/utils/time.js
@@ -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);
+}
diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js
new file mode 100644
index 000000000..dd1114ce7
--- /dev/null
+++ b/web_src/js/utils/time.test.js
@@ -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,
+ ]);
+});