mirror of
https://codeberg.org/forgejo/forgejo
synced 2024-11-29 13:16:10 +01:00
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
This commit is contained in:
parent
2933ae4e88
commit
1fa9662946
|
@ -6,11 +6,22 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
|
||||||
"github.com/go-xorm/xorm"
|
"github.com/go-xorm/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ActivityAuthorData represents statistical git commit count data
|
||||||
|
type ActivityAuthorData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
AvatarLink string `json:"avatar_link"`
|
||||||
|
Commits int64 `json:"commits"`
|
||||||
|
}
|
||||||
|
|
||||||
// ActivityStats represets issue and pull request information.
|
// ActivityStats represets issue and pull request information.
|
||||||
type ActivityStats struct {
|
type ActivityStats struct {
|
||||||
OpenedPRs PullRequestList
|
OpenedPRs PullRequestList
|
||||||
|
@ -24,32 +35,97 @@ type ActivityStats struct {
|
||||||
UnresolvedIssues IssueList
|
UnresolvedIssues IssueList
|
||||||
PublishedReleases []*Release
|
PublishedReleases []*Release
|
||||||
PublishedReleaseAuthorCount int64
|
PublishedReleaseAuthorCount int64
|
||||||
|
Code *git.CodeActivityStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActivityStats return stats for repository at given time range
|
// GetActivityStats return stats for repository at given time range
|
||||||
func GetActivityStats(repoID int64, timeFrom time.Time, releases, issues, prs bool) (*ActivityStats, error) {
|
func GetActivityStats(repo *Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) {
|
||||||
stats := &ActivityStats{}
|
stats := &ActivityStats{Code: &git.CodeActivityStats{}}
|
||||||
if releases {
|
if releases {
|
||||||
if err := stats.FillReleases(repoID, timeFrom); err != nil {
|
if err := stats.FillReleases(repo.ID, timeFrom); err != nil {
|
||||||
return nil, fmt.Errorf("FillReleases: %v", err)
|
return nil, fmt.Errorf("FillReleases: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if prs {
|
if prs {
|
||||||
if err := stats.FillPullRequests(repoID, timeFrom); err != nil {
|
if err := stats.FillPullRequests(repo.ID, timeFrom); err != nil {
|
||||||
return nil, fmt.Errorf("FillPullRequests: %v", err)
|
return nil, fmt.Errorf("FillPullRequests: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if issues {
|
if issues {
|
||||||
if err := stats.FillIssues(repoID, timeFrom); err != nil {
|
if err := stats.FillIssues(repo.ID, timeFrom); err != nil {
|
||||||
return nil, fmt.Errorf("FillIssues: %v", err)
|
return nil, fmt.Errorf("FillIssues: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := stats.FillUnresolvedIssues(repoID, timeFrom, issues, prs); err != nil {
|
if err := stats.FillUnresolvedIssues(repo.ID, timeFrom, issues, prs); err != nil {
|
||||||
return nil, fmt.Errorf("FillUnresolvedIssues: %v", err)
|
return nil, fmt.Errorf("FillUnresolvedIssues: %v", err)
|
||||||
}
|
}
|
||||||
|
if code {
|
||||||
|
gitRepo, err := git.OpenRepository(repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("OpenRepository: %v", err)
|
||||||
|
}
|
||||||
|
code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FillFromGit: %v", err)
|
||||||
|
}
|
||||||
|
stats.Code = code
|
||||||
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActivityStatsTopAuthors returns top author stats for git commits for all branches
|
||||||
|
func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int) ([]*ActivityAuthorData, error) {
|
||||||
|
gitRepo, err := git.OpenRepository(repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("OpenRepository: %v", err)
|
||||||
|
}
|
||||||
|
code, err := gitRepo.GetCodeActivityStats(timeFrom, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("FillFromGit: %v", err)
|
||||||
|
}
|
||||||
|
if code.Authors == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
users := make(map[int64]*ActivityAuthorData)
|
||||||
|
for k, v := range code.Authors {
|
||||||
|
if len(k) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := GetUserByEmail(k)
|
||||||
|
if u == nil || IsErrUserNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user, ok := users[u.ID]; !ok {
|
||||||
|
users[u.ID] = &ActivityAuthorData{
|
||||||
|
Name: u.DisplayName(),
|
||||||
|
Login: u.LowerName,
|
||||||
|
AvatarLink: u.AvatarLink(),
|
||||||
|
Commits: v,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.Commits += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v := make([]*ActivityAuthorData, 0)
|
||||||
|
for _, u := range users {
|
||||||
|
v = append(v, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(v[:], func(i, j int) bool {
|
||||||
|
return v[i].Commits < v[j].Commits
|
||||||
|
})
|
||||||
|
|
||||||
|
cnt := count
|
||||||
|
if cnt > len(v) {
|
||||||
|
cnt = len(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v[:cnt], nil
|
||||||
|
}
|
||||||
|
|
||||||
// ActivePRCount returns total active pull request count
|
// ActivePRCount returns total active pull request count
|
||||||
func (stats *ActivityStats) ActivePRCount() int {
|
func (stats *ActivityStats) ActivePRCount() int {
|
||||||
return stats.OpenedPRCount() + stats.MergedPRCount()
|
return stats.OpenedPRCount() + stats.MergedPRCount()
|
||||||
|
|
108
modules/git/repo_stats.go
Normal file
108
modules/git/repo_stats.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeActivityStats represents git statistics data
|
||||||
|
type CodeActivityStats struct {
|
||||||
|
AuthorCount int64
|
||||||
|
CommitCount int64
|
||||||
|
ChangedFiles int64
|
||||||
|
Additions int64
|
||||||
|
Deletions int64
|
||||||
|
CommitCountInAllBranches int64
|
||||||
|
Authors map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodeActivityStats returns code statistics for acitivity page
|
||||||
|
func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) {
|
||||||
|
stats := &CodeActivityStats{}
|
||||||
|
|
||||||
|
since := fromTime.Format(time.RFC3339)
|
||||||
|
|
||||||
|
stdout, err := NewCommand("rev-list", "--count", "--no-merges", "--branches=*", "--date=iso", fmt.Sprintf("--since='%s'", since)).RunInDirBytes(repo.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.CommitCountInAllBranches = c
|
||||||
|
|
||||||
|
args := []string{"log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%an%n%ae%n", "--date=iso", fmt.Sprintf("--since='%s'", since)}
|
||||||
|
if len(branch) == 0 {
|
||||||
|
args = append(args, "--branches=*")
|
||||||
|
} else {
|
||||||
|
args = append(args, "--first-parent", branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err = NewCommand(args...).RunInDirBytes(repo.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(stdout))
|
||||||
|
scanner.Split(bufio.ScanLines)
|
||||||
|
stats.CommitCount = 0
|
||||||
|
stats.Additions = 0
|
||||||
|
stats.Deletions = 0
|
||||||
|
authors := make(map[string]int64)
|
||||||
|
files := make(map[string]bool)
|
||||||
|
p := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
l := strings.TrimSpace(scanner.Text())
|
||||||
|
if l == "---" {
|
||||||
|
p = 1
|
||||||
|
} else if p == 0 {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
if p > 4 && len(l) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case 1: // Separator
|
||||||
|
case 2: // Commit sha-1
|
||||||
|
stats.CommitCount++
|
||||||
|
case 3: // Author
|
||||||
|
case 4: // E-mail
|
||||||
|
email := strings.ToLower(l)
|
||||||
|
i := authors[email]
|
||||||
|
authors[email] = i + 1
|
||||||
|
default: // Changed file
|
||||||
|
if parts := strings.Fields(l); len(parts) >= 3 {
|
||||||
|
if parts[0] != "-" {
|
||||||
|
if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil {
|
||||||
|
stats.Additions += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts[1] != "-" {
|
||||||
|
if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil {
|
||||||
|
stats.Deletions += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := files[parts[2]]; !ok {
|
||||||
|
files[parts[2]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.AuthorCount = int64(len(authors))
|
||||||
|
stats.ChangedFiles = int64(len(files))
|
||||||
|
stats.Authors = authors
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
35
modules/git/repo_stats_test.go
Normal file
35
modules/git/repo_stats_test.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepository_GetCodeActivityStats(t *testing.T) {
|
||||||
|
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||||
|
bareRepo1, err := OpenRepository(bareRepo1Path)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
timeFrom, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
code, err := bareRepo1.GetCodeActivityStats(timeFrom, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, code)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 8, code.CommitCount)
|
||||||
|
assert.EqualValues(t, 2, code.AuthorCount)
|
||||||
|
assert.EqualValues(t, 8, code.CommitCountInAllBranches)
|
||||||
|
assert.EqualValues(t, 10, code.Additions)
|
||||||
|
assert.EqualValues(t, 1, code.Deletions)
|
||||||
|
assert.Len(t, code.Authors, 2)
|
||||||
|
assert.Contains(t, code.Authors, "tris.git@shoddynet.org")
|
||||||
|
assert.EqualValues(t, 3, code.Authors["tris.git@shoddynet.org"])
|
||||||
|
assert.EqualValues(t, 5, code.Authors[""])
|
||||||
|
}
|
|
@ -1061,6 +1061,24 @@ activity.title.releases_1 = %d Release
|
||||||
activity.title.releases_n = %d Releases
|
activity.title.releases_n = %d Releases
|
||||||
activity.title.releases_published_by = %s published by %s
|
activity.title.releases_published_by = %s published by %s
|
||||||
activity.published_release_label = Published
|
activity.published_release_label = Published
|
||||||
|
activity.no_git_activity = There has not been any commit activity in this period.
|
||||||
|
activity.git_stats_exclude_merges = Excluding merges,
|
||||||
|
activity.git_stats_author_1 = %d author
|
||||||
|
activity.git_stats_author_n = %d authors
|
||||||
|
activity.git_stats_pushed = has pushed
|
||||||
|
activity.git_stats_commit_1 = %d commit
|
||||||
|
activity.git_stats_commit_n = %d commits
|
||||||
|
activity.git_stats_push_to_branch = to %s and
|
||||||
|
activity.git_stats_push_to_all_branches = to all branches.
|
||||||
|
activity.git_stats_on_default_branch = On %s,
|
||||||
|
activity.git_stats_file_1 = %d file
|
||||||
|
activity.git_stats_file_n = %d files
|
||||||
|
activity.git_stats_files_changed = have changed and there have been
|
||||||
|
activity.git_stats_addition_1 = %d addition
|
||||||
|
activity.git_stats_addition_n = %d additions
|
||||||
|
activity.git_stats_and_deletions = and
|
||||||
|
activity.git_stats_deletion_1 = %d deletion
|
||||||
|
activity.git_stats_deletion_n = %d deletions
|
||||||
|
|
||||||
search = Search
|
search = Search
|
||||||
search.search_repo = Search repository
|
search.search_repo = Search repository
|
||||||
|
|
|
@ -44,13 +44,42 @@ func Activity(ctx *context.Context) {
|
||||||
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string))
|
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository.ID, timeFrom,
|
if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository, timeFrom,
|
||||||
ctx.Repo.CanRead(models.UnitTypeReleases),
|
ctx.Repo.CanRead(models.UnitTypeReleases),
|
||||||
ctx.Repo.CanRead(models.UnitTypeIssues),
|
ctx.Repo.CanRead(models.UnitTypeIssues),
|
||||||
ctx.Repo.CanRead(models.UnitTypePullRequests)); err != nil {
|
ctx.Repo.CanRead(models.UnitTypePullRequests),
|
||||||
|
ctx.Repo.CanRead(models.UnitTypeCode)); err != nil {
|
||||||
ctx.ServerError("GetActivityStats", err)
|
ctx.ServerError("GetActivityStats", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.HTML(200, tplActivity)
|
ctx.HTML(200, tplActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityAuthors renders JSON with top commit authors for given time period over all branches
|
||||||
|
func ActivityAuthors(ctx *context.Context) {
|
||||||
|
timeUntil := time.Now()
|
||||||
|
var timeFrom time.Time
|
||||||
|
|
||||||
|
switch ctx.Params("period") {
|
||||||
|
case "daily":
|
||||||
|
timeFrom = timeUntil.Add(-time.Hour * 24)
|
||||||
|
case "halfweekly":
|
||||||
|
timeFrom = timeUntil.Add(-time.Hour * 72)
|
||||||
|
case "weekly":
|
||||||
|
timeFrom = timeUntil.Add(-time.Hour * 168)
|
||||||
|
case "monthly":
|
||||||
|
timeFrom = timeUntil.AddDate(0, -1, 0)
|
||||||
|
default:
|
||||||
|
timeFrom = timeUntil.Add(-time.Hour * 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
authors, err := models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetActivityStatsTopAuthors", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(200, authors)
|
||||||
|
}
|
||||||
|
|
|
@ -802,6 +802,11 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Get("/:period", repo.Activity)
|
m.Get("/:period", repo.Activity)
|
||||||
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypePullRequests, models.UnitTypeIssues, models.UnitTypeReleases))
|
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypePullRequests, models.UnitTypeIssues, models.UnitTypeReleases))
|
||||||
|
|
||||||
|
m.Group("/activity_author_data", func() {
|
||||||
|
m.Get("", repo.ActivityAuthors)
|
||||||
|
m.Get("/:period", repo.ActivityAuthors)
|
||||||
|
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(models.UnitTypeCode))
|
||||||
|
|
||||||
m.Get("/archive/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.Download)
|
m.Get("/archive/*", repo.MustBeNotEmpty, reqRepoCodeReader, repo.Download)
|
||||||
|
|
||||||
m.Group("/branches", func() {
|
m.Group("/branches", func() {
|
||||||
|
|
|
@ -81,6 +81,33 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Permission.CanRead $.UnitTypeCode}}
|
||||||
|
{{if eq .Activity.Code.CommitCountInAllBranches 0}}
|
||||||
|
<div class="ui center aligned segment">
|
||||||
|
<h4 class="ui header">{{.i18n.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">
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_exclude_merges" }}
|
||||||
|
<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>
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_pushed" }}
|
||||||
|
<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>
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch }}
|
||||||
|
<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>
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_push_to_all_branches" }}
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch }}
|
||||||
|
<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>
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_files_changed" }}
|
||||||
|
<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>
|
||||||
|
{{.i18n.Tr "repo.activity.git_stats_and_deletions" }}
|
||||||
|
<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>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if gt .Activity.PublishedReleaseCount 0}}
|
{{if gt .Activity.PublishedReleaseCount 0}}
|
||||||
<h4 class="ui horizontal divider header" id="published-releases">
|
<h4 class="ui horizontal divider header" id="published-releases">
|
||||||
<i class="text octicon octicon-tag"></i>
|
<i class="text octicon octicon-tag"></i>
|
||||||
|
|
Loading…
Reference in a new issue