forked from gitea/gitea
		
	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 ( | ||||
| 	"fmt" | ||||
| 	"sort" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 
 | ||||
| 	"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. | ||||
| type ActivityStats struct { | ||||
| 	OpenedPRs                   PullRequestList | ||||
| @ -24,32 +35,97 @@ type ActivityStats struct { | ||||
| 	UnresolvedIssues            IssueList | ||||
| 	PublishedReleases           []*Release | ||||
| 	PublishedReleaseAuthorCount int64 | ||||
| 	Code                        *git.CodeActivityStats | ||||
| } | ||||
| 
 | ||||
| // GetActivityStats return stats for repository at given time range | ||||
| func GetActivityStats(repoID int64, timeFrom time.Time, releases, issues, prs bool) (*ActivityStats, error) { | ||||
| 	stats := &ActivityStats{} | ||||
| func GetActivityStats(repo *Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) { | ||||
| 	stats := &ActivityStats{Code: &git.CodeActivityStats{}} | ||||
| 	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) | ||||
| 		} | ||||
| 	} | ||||
| 	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) | ||||
| 		} | ||||
| 	} | ||||
| 	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) | ||||
| 		} | ||||
| 	} | ||||
| 	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) | ||||
| 	} | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| // 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 | ||||
| func (stats *ActivityStats) ActivePRCount() int { | ||||
| 	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_published_by = %s published by %s | ||||
| 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_repo = Search repository | ||||
|  | ||||
| @ -44,13 +44,42 @@ func Activity(ctx *context.Context) { | ||||
| 	ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) | ||||
| 
 | ||||
| 	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.UnitTypeIssues), | ||||
| 		ctx.Repo.CanRead(models.UnitTypePullRequests)); err != nil { | ||||
| 		ctx.Repo.CanRead(models.UnitTypePullRequests), | ||||
| 		ctx.Repo.CanRead(models.UnitTypeCode)); err != nil { | ||||
| 		ctx.ServerError("GetActivityStats", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	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) | ||||
| 		}, 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.Group("/branches", func() { | ||||
|  | ||||
| @ -81,6 +81,33 @@ | ||||
| 		</div> | ||||
| 		{{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}} | ||||
| 			<h4 class="ui horizontal divider header" id="published-releases"> | ||||
| 				<i class="text octicon octicon-tag"></i> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Lauris BH
						Lauris BH