diff --git a/models/repo_activity.go b/models/repo_activity.go index c3017e8e3..fb1385a54 100644 --- a/models/repo_activity.go +++ b/models/repo_activity.go @@ -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() diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go new file mode 100644 index 000000000..aa62e7420 --- /dev/null +++ b/modules/git/repo_stats.go @@ -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 +} diff --git a/modules/git/repo_stats_test.go b/modules/git/repo_stats_test.go new file mode 100644 index 000000000..2e8565b9e --- /dev/null +++ b/modules/git/repo_stats_test.go @@ -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[""]) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index eedede2a0..fe90d6545 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 diff --git a/routers/repo/activity.go b/routers/repo/activity.go index 5d90d7350..e170a9129 100644 --- a/routers/repo/activity.go +++ b/routers/repo/activity.go @@ -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) +} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 5fa37a841..938afcab7 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -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() { diff --git a/templates/repo/activity.tmpl b/templates/repo/activity.tmpl index 2b8fbc6c1..5b6559c8b 100644 --- a/templates/repo/activity.tmpl +++ b/templates/repo/activity.tmpl @@ -81,6 +81,33 @@ {{end}} + {{if .Permission.CanRead $.UnitTypeCode}} + {{if eq .Activity.Code.CommitCountInAllBranches 0}} +
+

{{.i18n.Tr "repo.activity.no_git_activity" }}

+
+ {{end}} + {{if gt .Activity.Code.CommitCountInAllBranches 0}} +
+
+ {{.i18n.Tr "repo.activity.git_stats_exclude_merges" }} + {{.i18n.Tr (TrN .i18n.Lang .Activity.Code.AuthorCount "repo.activity.git_stats_author_1" "repo.activity.git_stats_author_n") .Activity.Code.AuthorCount }} + {{.i18n.Tr "repo.activity.git_stats_pushed" }} + {{.i18n.Tr (TrN .i18n.Lang .Activity.Code.CommitCount "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n") .Activity.Code.CommitCount }} + {{.i18n.Tr "repo.activity.git_stats_push_to_branch" .Repository.DefaultBranch }} + {{.i18n.Tr (TrN .i18n.Lang .Activity.Code.CommitCountInAllBranches "repo.activity.git_stats_commit_1" "repo.activity.git_stats_commit_n") .Activity.Code.CommitCountInAllBranches }} + {{.i18n.Tr "repo.activity.git_stats_push_to_all_branches" }} + {{.i18n.Tr "repo.activity.git_stats_on_default_branch" .Repository.DefaultBranch }} + {{.i18n.Tr (TrN .i18n.Lang .Activity.Code.ChangedFiles "repo.activity.git_stats_file_1" "repo.activity.git_stats_file_n") .Activity.Code.ChangedFiles }} + {{.i18n.Tr "repo.activity.git_stats_files_changed" }} + {{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Additions "repo.activity.git_stats_addition_1" "repo.activity.git_stats_addition_n") .Activity.Code.Additions }} + {{.i18n.Tr "repo.activity.git_stats_and_deletions" }} + {{.i18n.Tr (TrN .i18n.Lang .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}}