Skip to content

Commit 1fa9662

Browse files
authored
Git statistics in Activity tab (#4724)
* Initial implementation for git statistics in Activity tab * Create top user by commit count endpoint * Add UI and update src-d/go-git dependency * Add coloring * Fix typo * Move git activity stats data extraction to git module * Fix message * Add git code stats test
1 parent 2933ae4 commit 1fa9662

File tree

7 files changed

+306
-8
lines changed

7 files changed

+306
-8
lines changed

models/repo_activity.go

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@ package models
66

77
import (
88
"fmt"
9+
"sort"
910
"time"
1011

12+
"code.gitea.io/gitea/modules/git"
13+
1114
"github.com/go-xorm/xorm"
1215
)
1316

17+
// ActivityAuthorData represents statistical git commit count data
18+
type ActivityAuthorData struct {
19+
Name string `json:"name"`
20+
Login string `json:"login"`
21+
AvatarLink string `json:"avatar_link"`
22+
Commits int64 `json:"commits"`
23+
}
24+
1425
// ActivityStats represets issue and pull request information.
1526
type ActivityStats struct {
1627
OpenedPRs PullRequestList
@@ -24,32 +35,97 @@ type ActivityStats struct {
2435
UnresolvedIssues IssueList
2536
PublishedReleases []*Release
2637
PublishedReleaseAuthorCount int64
38+
Code *git.CodeActivityStats
2739
}
2840

2941
// GetActivityStats return stats for repository at given time range
30-
func GetActivityStats(repoID int64, timeFrom time.Time, releases, issues, prs bool) (*ActivityStats, error) {
31-
stats := &ActivityStats{}
42+
func GetActivityStats(repo *Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) {
43+
stats := &ActivityStats{Code: &git.CodeActivityStats{}}
3244
if releases {
33-
if err := stats.FillReleases(repoID, timeFrom); err != nil {
45+
if err := stats.FillReleases(repo.ID, timeFrom); err != nil {
3446
return nil, fmt.Errorf("FillReleases: %v", err)
3547
}
3648
}
3749
if prs {
38-
if err := stats.FillPullRequests(repoID, timeFrom); err != nil {
50+
if err := stats.FillPullRequests(repo.ID, timeFrom); err != nil {
3951
return nil, fmt.Errorf("FillPullRequests: %v", err)
4052
}
4153
}
4254
if issues {
43-
if err := stats.FillIssues(repoID, timeFrom); err != nil {
55+
if err := stats.FillIssues(repo.ID, timeFrom); err != nil {
4456
return nil, fmt.Errorf("FillIssues: %v", err)
4557
}
4658
}
47-
if err := stats.FillUnresolvedIssues(repoID, timeFrom, issues, prs); err != nil {
59+
if err := stats.FillUnresolvedIssues(repo.ID, timeFrom, issues, prs); err != nil {
4860
return nil, fmt.Errorf("FillUnresolvedIssues: %v", err)
4961
}
62+
if code {
63+
gitRepo, err := git.OpenRepository(repo.RepoPath())
64+
if err != nil {
65+
return nil, fmt.Errorf("OpenRepository: %v", err)
66+
}
67+
code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch)
68+
if err != nil {
69+
return nil, fmt.Errorf("FillFromGit: %v", err)
70+
}
71+
stats.Code = code
72+
}
5073
return stats, nil
5174
}
5275

76+
// GetActivityStatsTopAuthors returns top author stats for git commits for all branches
77+
func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int) ([]*ActivityAuthorData, error) {
78+
gitRepo, err := git.OpenRepository(repo.RepoPath())
79+
if err != nil {
80+
return nil, fmt.Errorf("OpenRepository: %v", err)
81+
}
82+
code, err := gitRepo.GetCodeActivityStats(timeFrom, "")
83+
if err != nil {
84+
return nil, fmt.Errorf("FillFromGit: %v", err)
85+
}
86+
if code.Authors == nil {
87+
return nil, nil
88+
}
89+
users := make(map[int64]*ActivityAuthorData)
90+
for k, v := range code.Authors {
91+
if len(k) == 0 {
92+
continue
93+
}
94+
u, err := GetUserByEmail(k)
95+
if u == nil || IsErrUserNotExist(err) {
96+
continue
97+
}
98+
if err != nil {
99+
return nil, err
100+
}
101+
if user, ok := users[u.ID]; !ok {
102+
users[u.ID] = &ActivityAuthorData{
103+
Name: u.DisplayName(),
104+
Login: u.LowerName,
105+
AvatarLink: u.AvatarLink(),
106+
Commits: v,
107+
}
108+
} else {
109+
user.Commits += v
110+
}
111+
}
112+
v := make([]*ActivityAuthorData, 0)
113+
for _, u := range users {
114+
v = append(v, u)
115+
}
116+
117+
sort.Slice(v[:], func(i, j int) bool {
118+
return v[i].Commits < v[j].Commits
119+
})
120+
121+
cnt := count
122+
if cnt > len(v) {
123+
cnt = len(v)
124+
}
125+
126+
return v[:cnt], nil
127+
}
128+
53129
// ActivePRCount returns total active pull request count
54130
func (stats *ActivityStats) ActivePRCount() int {
55131
return stats.OpenedPRCount() + stats.MergedPRCount()

modules/git/repo_stats.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package git
6+
7+
import (
8+
"bufio"
9+
"bytes"
10+
"fmt"
11+
"strconv"
12+
"strings"
13+
"time"
14+
)
15+
16+
// CodeActivityStats represents git statistics data
17+
type CodeActivityStats struct {
18+
AuthorCount int64
19+
CommitCount int64
20+
ChangedFiles int64
21+
Additions int64
22+
Deletions int64
23+
CommitCountInAllBranches int64
24+
Authors map[string]int64
25+
}
26+
27+
// GetCodeActivityStats returns code statistics for acitivity page
28+
func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) {
29+
stats := &CodeActivityStats{}
30+
31+
since := fromTime.Format(time.RFC3339)
32+
33+
stdout, err := NewCommand("rev-list", "--count", "--no-merges", "--branches=*", "--date=iso", fmt.Sprintf("--since='%s'", since)).RunInDirBytes(repo.Path)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
c, err := strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64)
39+
if err != nil {
40+
return nil, err
41+
}
42+
stats.CommitCountInAllBranches = c
43+
44+
args := []string{"log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%an%n%ae%n", "--date=iso", fmt.Sprintf("--since='%s'", since)}
45+
if len(branch) == 0 {
46+
args = append(args, "--branches=*")
47+
} else {
48+
args = append(args, "--first-parent", branch)
49+
}
50+
51+
stdout, err = NewCommand(args...).RunInDirBytes(repo.Path)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
scanner := bufio.NewScanner(bytes.NewReader(stdout))
57+
scanner.Split(bufio.ScanLines)
58+
stats.CommitCount = 0
59+
stats.Additions = 0
60+
stats.Deletions = 0
61+
authors := make(map[string]int64)
62+
files := make(map[string]bool)
63+
p := 0
64+
for scanner.Scan() {
65+
l := strings.TrimSpace(scanner.Text())
66+
if l == "---" {
67+
p = 1
68+
} else if p == 0 {
69+
continue
70+
} else {
71+
p++
72+
}
73+
if p > 4 && len(l) == 0 {
74+
continue
75+
}
76+
switch p {
77+
case 1: // Separator
78+
case 2: // Commit sha-1
79+
stats.CommitCount++
80+
case 3: // Author
81+
case 4: // E-mail
82+
email := strings.ToLower(l)
83+
i := authors[email]
84+
authors[email] = i + 1
85+
default: // Changed file
86+
if parts := strings.Fields(l); len(parts) >= 3 {
87+
if parts[0] != "-" {
88+
if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil {
89+
stats.Additions += c
90+
}
91+
}
92+
if parts[1] != "-" {
93+
if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil {
94+
stats.Deletions += c
95+
}
96+
}
97+
if _, ok := files[parts[2]]; !ok {
98+
files[parts[2]] = true
99+
}
100+
}
101+
}
102+
}
103+
stats.AuthorCount = int64(len(authors))
104+
stats.ChangedFiles = int64(len(files))
105+
stats.Authors = authors
106+
107+
return stats, nil
108+
}

modules/git/repo_stats_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package git
6+
7+
import (
8+
"path/filepath"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestRepository_GetCodeActivityStats(t *testing.T) {
16+
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
17+
bareRepo1, err := OpenRepository(bareRepo1Path)
18+
assert.NoError(t, err)
19+
20+
timeFrom, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00")
21+
22+
code, err := bareRepo1.GetCodeActivityStats(timeFrom, "")
23+
assert.NoError(t, err)
24+
assert.NotNil(t, code)
25+
26+
assert.EqualValues(t, 8, code.CommitCount)
27+
assert.EqualValues(t, 2, code.AuthorCount)
28+
assert.EqualValues(t, 8, code.CommitCountInAllBranches)
29+
assert.EqualValues(t, 10, code.Additions)
30+
assert.EqualValues(t, 1, code.Deletions)
31+
assert.Len(t, code.Authors, 2)
32+
assert.Contains(t, code.Authors, "[email protected]")
33+
assert.EqualValues(t, 3, code.Authors["[email protected]"])
34+
assert.EqualValues(t, 5, code.Authors[""])
35+
}

options/locale/locale_en-US.ini

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,24 @@ activity.title.releases_1 = %d Release
10611061
activity.title.releases_n = %d Releases
10621062
activity.title.releases_published_by = %s published by %s
10631063
activity.published_release_label = Published
1064+
activity.no_git_activity = There has not been any commit activity in this period.
1065+
activity.git_stats_exclude_merges = Excluding merges,
1066+
activity.git_stats_author_1 = %d author
1067+
activity.git_stats_author_n = %d authors
1068+
activity.git_stats_pushed = has pushed
1069+
activity.git_stats_commit_1 = %d commit
1070+
activity.git_stats_commit_n = %d commits
1071+
activity.git_stats_push_to_branch = to %s and
1072+
activity.git_stats_push_to_all_branches = to all branches.
1073+
activity.git_stats_on_default_branch = On %s,
1074+
activity.git_stats_file_1 = %d file
1075+
activity.git_stats_file_n = %d files
1076+
activity.git_stats_files_changed = have changed and there have been
1077+
activity.git_stats_addition_1 = %d addition
1078+
activity.git_stats_addition_n = %d additions
1079+
activity.git_stats_and_deletions = and
1080+
activity.git_stats_deletion_1 = %d deletion
1081+
activity.git_stats_deletion_n = %d deletions
10641082
10651083
search = Search
10661084
search.search_repo = Search repository

routers/repo/activity.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,42 @@ func Activity(ctx *context.Context) {
4444
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string))
4545

4646
var err error
47-
if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository.ID, timeFrom,
47+
if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository, timeFrom,
4848
ctx.Repo.CanRead(models.UnitTypeReleases),
4949
ctx.Repo.CanRead(models.UnitTypeIssues),
50-
ctx.Repo.CanRead(models.UnitTypePullRequests)); err != nil {
50+
ctx.Repo.CanRead(models.UnitTypePullRequests),
51+
ctx.Repo.CanRead(models.UnitTypeCode)); err != nil {
5152
ctx.ServerError("GetActivityStats", err)
5253
return
5354
}
5455

5556
ctx.HTML(200, tplActivity)
5657
}
58+
59+
// ActivityAuthors renders JSON with top commit authors for given time period over all branches
60+
func ActivityAuthors(ctx *context.Context) {
61+
timeUntil := time.Now()
62+
var timeFrom time.Time
63+
64+
switch ctx.Params("period") {
65+
case "daily":
66+
timeFrom = timeUntil.Add(-time.Hour * 24)
67+
case "halfweekly":
68+
timeFrom = timeUntil.Add(-time.Hour * 72)
69+
case "weekly":
70+
timeFrom = timeUntil.Add(-time.Hour * 168)
71+
case "monthly":
72+
timeFrom = timeUntil.AddDate(0, -1, 0)
73+
default:
74+
timeFrom = timeUntil.Add(-time.Hour * 168)
75+
}
76+
77+
var err error
78+
authors, err := models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10)
79+
if err != nil {
80+
ctx.ServerError("GetActivityStatsTopAuthors", err)
81+
return
82+
}
83+
84+
ctx.JSON(200, authors)
85+
}

routers/routes/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,11 @@ func RegisterRoutes(m *macaron.Macaron) {
802802
m.Get("/:period", repo.Activity)
803803
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypePullRequests, models.UnitTypeIssues, models.UnitTypeReleases))
804804

805+
m.Group("/activity_author_data", func() {
806+
m.Get("", repo.ActivityAuthors)
807+
m.Get("/:period", repo.ActivityAuthors)
808+
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypeCode))
809+
805810
m.Get("/archive/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.Download)
806811

807812
m.Group("/branches", func() {

templates/repo/activity.tmpl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,33 @@
8181
</div>
8282
{{end}}
8383

84+
{{if .Permission.CanRead $.UnitTypeCode}}
85+
{{if eq .Activity.Code.CommitCountInAllBranches 0}}
86+
<div class="ui center aligned segment">
87+
<h4 class="ui header">{{.i18n.Tr "repo.activity.no_git_activity" }}</h4>
88+
</div>
89+
{{end}}
90+
{{if gt .Activity.Code.CommitCountInAllBranches 0}}
91+
<div class="ui attached segment horizontal segments">
92+
<div class="ui attached segment text">
93+
{{.i18n.Tr "repo.activity.git_stats_exclude_merges" }}
94+
<strong>{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n") .Activity.Code.AuthorCount }}</strong>
95+
{{.i18n.Tr "repo.activity.git_stats_pushed" }}
96+
<strong>{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n") .Activity.Code.CommitCount }}</strong>
97+
{{.i18n.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch }}
98+
<strong>{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n") .Activity.Code.CommitCountInAllBranches }}</strong>
99+
{{.i18n.Tr "repo.activity.git_stats_push_to_all_branches" }}
100+
{{.i18n.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch }}
101+
<strong>{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n") .Activity.Code.ChangedFiles }}</strong>
102+
{{.i18n.Tr "repo.activity.git_stats_files_changed" }}
103+
<strong class="text green">{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n") .Activity.Code.Additions }}</strong>
104+
{{.i18n.Tr "repo.activity.git_stats_and_deletions" }}
105+
<strong class="text red">{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n") .Activity.Code.Deletions }}</strong>.
106+
</div>
107+
</div>
108+
{{end}}
109+
{{end}}
110+
84111
{{if gt .Activity.PublishedReleaseCount 0}}
85112
<h4 class="ui horizontal divider header" id="published-releases">
86113
<i class="text octicon octicon-tag"></i>

0 commit comments

Comments
 (0)