mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-24 07:40:00 +00:00
fa0b39a42b
`published_at` was an alias for `created_at`, so a release created from an existing tag reported that tag's commit date as its publication time, and drafts reported one despite never having been published. It is now stored separately, set when a release is published and null for drafts. `created_at` in turn means the date of the commit the release points at, matching what GitHub documents it to be, and the latest release is selected by it again. Publishing a release for an old commit no longer takes over the latest badge, and a tag created in the web UI is dated the same way as one pushed from the CLI. Fixes https://github.com/go-gitea/gitea/issues/11206 Fixes https://github.com/go-gitea/gitea/issues/38714 Fixes https://github.com/go-gitea/gitea/issues/31789 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
391 lines
12 KiB
Go
391 lines
12 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package activities
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"gitea.dev/models/db"
|
|
issues_model "gitea.dev/models/issues"
|
|
repo_model "gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/git"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// ActivityAuthorData represents statistical git commit count data
|
|
type ActivityAuthorData struct {
|
|
Name string `json:"name"`
|
|
Login string `json:"login"`
|
|
AvatarLink string `json:"avatar_link"`
|
|
HomeLink string `json:"home_link"`
|
|
Commits int64 `json:"commits"`
|
|
}
|
|
|
|
// ActivityStats represents issue and pull request information.
|
|
type ActivityStats struct {
|
|
OpenedPRs issues_model.PullRequestList
|
|
OpenedPRAuthorCount int64
|
|
MergedPRs issues_model.PullRequestList
|
|
MergedPRAuthorCount int64
|
|
ActiveIssues issues_model.IssueList
|
|
OpenedIssues issues_model.IssueList
|
|
OpenedIssueAuthorCount int64
|
|
ClosedIssues issues_model.IssueList
|
|
ClosedIssueAuthorCount int64
|
|
UnresolvedIssues issues_model.IssueList
|
|
PublishedReleases []*repo_model.Release
|
|
PublishedReleaseAuthorCount int64
|
|
Code *git.CodeActivityStats
|
|
}
|
|
|
|
// GetActivityStats return stats for repository at given time range
|
|
func GetActivityStats(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) {
|
|
stats := &ActivityStats{Code: &git.CodeActivityStats{}}
|
|
if releases {
|
|
if err := stats.FillReleases(ctx, repo.ID, timeFrom); err != nil {
|
|
return nil, fmt.Errorf("FillReleases: %w", err)
|
|
}
|
|
}
|
|
if prs {
|
|
if err := stats.FillPullRequests(ctx, repo.ID, timeFrom); err != nil {
|
|
return nil, fmt.Errorf("FillPullRequests: %w", err)
|
|
}
|
|
}
|
|
if issues {
|
|
if err := stats.FillIssues(ctx, repo.ID, timeFrom); err != nil {
|
|
return nil, fmt.Errorf("FillIssues: %w", err)
|
|
}
|
|
}
|
|
if err := stats.FillUnresolvedIssues(ctx, repo.ID, timeFrom, issues, prs); err != nil {
|
|
return nil, fmt.Errorf("FillUnresolvedIssues: %w", err)
|
|
}
|
|
if code {
|
|
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("OpenRepository: %w", err)
|
|
}
|
|
defer closer.Close()
|
|
|
|
code, err := gitRepo.GetCodeActivityStats(ctx, timeFrom, repo.DefaultBranch)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("FillFromGit: %w", err)
|
|
}
|
|
stats.Code = code
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
// GetActivityStatsTopAuthors returns top author stats for git commits for all branches
|
|
func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, count int) ([]*ActivityAuthorData, error) {
|
|
gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("OpenRepository: %w", err)
|
|
}
|
|
defer closer.Close()
|
|
|
|
code, err := gitRepo.GetCodeActivityStats(ctx, timeFrom, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("FillFromGit: %w", err)
|
|
}
|
|
if code.Authors == nil {
|
|
return nil, nil
|
|
}
|
|
users := make(map[int64]*ActivityAuthorData)
|
|
var unknownUserID int64
|
|
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink(ctx)
|
|
for _, v := range code.Authors {
|
|
if len(v.Email) == 0 {
|
|
continue
|
|
}
|
|
u, err := user_model.GetUserByEmail(ctx, v.Email)
|
|
if u == nil || user_model.IsErrUserNotExist(err) {
|
|
unknownUserID--
|
|
users[unknownUserID] = &ActivityAuthorData{
|
|
Name: v.Name,
|
|
AvatarLink: unknownUserAvatarLink,
|
|
Commits: v.Commits,
|
|
}
|
|
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(ctx),
|
|
HomeLink: u.HomeLink(),
|
|
Commits: v.Commits,
|
|
}
|
|
} else {
|
|
user.Commits += v.Commits
|
|
}
|
|
}
|
|
v := make([]*ActivityAuthorData, 0, len(users))
|
|
for _, u := range users {
|
|
v = append(v, u)
|
|
}
|
|
|
|
sort.Slice(v, func(i, j int) bool {
|
|
return v[i].Commits > v[j].Commits
|
|
})
|
|
|
|
cnt := min(count, len(v))
|
|
|
|
return v[:cnt], nil
|
|
}
|
|
|
|
// ActivePRCount returns total active pull request count
|
|
func (stats *ActivityStats) ActivePRCount() int {
|
|
return stats.OpenedPRCount() + stats.MergedPRCount()
|
|
}
|
|
|
|
// OpenedPRCount returns opened pull request count
|
|
func (stats *ActivityStats) OpenedPRCount() int {
|
|
return len(stats.OpenedPRs)
|
|
}
|
|
|
|
// OpenedPRPerc returns opened pull request percents from total active
|
|
func (stats *ActivityStats) OpenedPRPerc() int {
|
|
return int(float32(stats.OpenedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
|
|
}
|
|
|
|
// MergedPRCount returns merged pull request count
|
|
func (stats *ActivityStats) MergedPRCount() int {
|
|
return len(stats.MergedPRs)
|
|
}
|
|
|
|
// MergedPRPerc returns merged pull request percent from total active
|
|
func (stats *ActivityStats) MergedPRPerc() int {
|
|
return int(float32(stats.MergedPRCount()) / float32(stats.ActivePRCount()) * 100.0)
|
|
}
|
|
|
|
// ActiveIssueCount returns total active issue count
|
|
func (stats *ActivityStats) ActiveIssueCount() int {
|
|
return len(stats.ActiveIssues)
|
|
}
|
|
|
|
// OpenedIssueCount returns open issue count
|
|
func (stats *ActivityStats) OpenedIssueCount() int {
|
|
return len(stats.OpenedIssues)
|
|
}
|
|
|
|
// OpenedIssuePerc returns open issue count percent from total active
|
|
func (stats *ActivityStats) OpenedIssuePerc() int {
|
|
return int(float32(stats.OpenedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
|
|
}
|
|
|
|
// ClosedIssueCount returns closed issue count
|
|
func (stats *ActivityStats) ClosedIssueCount() int {
|
|
return len(stats.ClosedIssues)
|
|
}
|
|
|
|
// ClosedIssuePerc returns closed issue count percent from total active
|
|
func (stats *ActivityStats) ClosedIssuePerc() int {
|
|
return int(float32(stats.ClosedIssueCount()) / float32(stats.ActiveIssueCount()) * 100.0)
|
|
}
|
|
|
|
// UnresolvedIssueCount returns unresolved issue and pull request count
|
|
func (stats *ActivityStats) UnresolvedIssueCount() int {
|
|
return len(stats.UnresolvedIssues)
|
|
}
|
|
|
|
// PublishedReleaseCount returns published release count
|
|
func (stats *ActivityStats) PublishedReleaseCount() int {
|
|
return len(stats.PublishedReleases)
|
|
}
|
|
|
|
// FillPullRequests returns pull request information for activity page
|
|
func (stats *ActivityStats) FillPullRequests(ctx context.Context, repoID int64, fromTime time.Time) error {
|
|
var err error
|
|
var count int64
|
|
|
|
// Merged pull requests
|
|
sess := pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
|
|
sess.OrderBy("pull_request.merged_unix DESC")
|
|
stats.MergedPRs = make(issues_model.PullRequestList, 0)
|
|
if err = sess.Find(&stats.MergedPRs); err != nil {
|
|
return err
|
|
}
|
|
if err = stats.MergedPRs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Merged pull request authors
|
|
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, true)
|
|
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
|
|
return err
|
|
}
|
|
stats.MergedPRAuthorCount = count
|
|
|
|
// Opened pull requests
|
|
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
|
|
sess.OrderBy("issue.created_unix ASC")
|
|
stats.OpenedPRs = make(issues_model.PullRequestList, 0)
|
|
if err = sess.Find(&stats.OpenedPRs); err != nil {
|
|
return err
|
|
}
|
|
if err = stats.OpenedPRs.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Opened pull request authors
|
|
sess = pullRequestsForActivityStatement(ctx, repoID, fromTime, false)
|
|
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("pull_request").Get(&count); err != nil {
|
|
return err
|
|
}
|
|
stats.OpenedPRAuthorCount = count
|
|
|
|
return nil
|
|
}
|
|
|
|
func pullRequestsForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, merged bool) db.Session {
|
|
sess := db.GetEngine(ctx).Where("pull_request.base_repo_id=?", repoID).
|
|
Join("INNER", "issue", "pull_request.issue_id = issue.id")
|
|
|
|
if merged {
|
|
sess.And("pull_request.has_merged = ?", true)
|
|
sess.And("pull_request.merged_unix >= ?", fromTime.Unix())
|
|
} else {
|
|
sess.And("issue.is_closed = ?", false)
|
|
sess.And("issue.created_unix >= ?", fromTime.Unix())
|
|
}
|
|
|
|
return sess
|
|
}
|
|
|
|
// FillIssues returns issue information for activity page
|
|
func (stats *ActivityStats) FillIssues(ctx context.Context, repoID int64, fromTime time.Time) error {
|
|
var err error
|
|
var count int64
|
|
|
|
// Closed issues
|
|
sess := issuesForActivityStatement(ctx, repoID, fromTime, true, false)
|
|
sess.OrderBy("issue.closed_unix DESC")
|
|
stats.ClosedIssues = make(issues_model.IssueList, 0)
|
|
if err = sess.Find(&stats.ClosedIssues); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Closed issue authors
|
|
sess = issuesForActivityStatement(ctx, repoID, fromTime, true, false)
|
|
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
|
|
return err
|
|
}
|
|
stats.ClosedIssueAuthorCount = count
|
|
|
|
// New issues
|
|
sess = newlyCreatedIssues(ctx, repoID, fromTime)
|
|
sess.OrderBy("issue.created_unix ASC")
|
|
stats.OpenedIssues = make(issues_model.IssueList, 0)
|
|
if err = sess.Find(&stats.OpenedIssues); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Active issues
|
|
sess = activeIssues(ctx, repoID, fromTime)
|
|
sess.OrderBy("issue.created_unix ASC")
|
|
stats.ActiveIssues = make(issues_model.IssueList, 0)
|
|
if err = sess.Find(&stats.ActiveIssues); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Opened issue authors
|
|
sess = issuesForActivityStatement(ctx, repoID, fromTime, false, false)
|
|
if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil {
|
|
return err
|
|
}
|
|
stats.OpenedIssueAuthorCount = count
|
|
|
|
return nil
|
|
}
|
|
|
|
// FillUnresolvedIssues returns unresolved issue and pull request information for activity page
|
|
func (stats *ActivityStats) FillUnresolvedIssues(ctx context.Context, repoID int64, fromTime time.Time, issues, prs bool) error {
|
|
// Check if we need to select anything
|
|
if !issues && !prs {
|
|
return nil
|
|
}
|
|
sess := issuesForActivityStatement(ctx, repoID, fromTime, false, true)
|
|
if !issues || !prs {
|
|
sess.And("issue.is_pull = ?", prs)
|
|
}
|
|
sess.OrderBy("issue.updated_unix DESC")
|
|
stats.UnresolvedIssues = make(issues_model.IssueList, 0)
|
|
return sess.Find(&stats.UnresolvedIssues)
|
|
}
|
|
|
|
func newlyCreatedIssues(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
|
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
|
And("issue.is_pull = ?", false). // Retain the is_pull check to exclude pull requests
|
|
And("issue.created_unix >= ?", fromTime.Unix()) // Include all issues created after fromTime
|
|
|
|
return sess
|
|
}
|
|
|
|
func activeIssues(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
|
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
|
And("issue.is_pull = ?", false).
|
|
And(builder.Or(
|
|
builder.Gte{"issue.created_unix": fromTime.Unix()},
|
|
builder.Gte{"issue.closed_unix": fromTime.Unix()},
|
|
))
|
|
|
|
return sess
|
|
}
|
|
|
|
func issuesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time, closed, unresolved bool) db.Session {
|
|
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
|
|
And("issue.is_closed = ?", closed)
|
|
|
|
if !unresolved {
|
|
sess.And("issue.is_pull = ?", false)
|
|
if closed {
|
|
sess.And("issue.closed_unix >= ?", fromTime.Unix())
|
|
} else {
|
|
sess.And("issue.created_unix >= ?", fromTime.Unix())
|
|
}
|
|
} else {
|
|
sess.And("issue.created_unix < ?", fromTime.Unix())
|
|
sess.And("issue.updated_unix >= ?", fromTime.Unix())
|
|
}
|
|
|
|
return sess
|
|
}
|
|
|
|
// FillReleases returns release information for activity page
|
|
func (stats *ActivityStats) FillReleases(ctx context.Context, repoID int64, fromTime time.Time) error {
|
|
var err error
|
|
var count int64
|
|
|
|
// Published releases list
|
|
sess := releasesForActivityStatement(ctx, repoID, fromTime)
|
|
sess.OrderBy("`release`.published_unix DESC")
|
|
stats.PublishedReleases = make([]*repo_model.Release, 0)
|
|
if err = sess.Find(&stats.PublishedReleases); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Published releases authors
|
|
sess = releasesForActivityStatement(ctx, repoID, fromTime)
|
|
if _, err = sess.Select("count(distinct `release`.publisher_id) as `count`").Table("release").Get(&count); err != nil {
|
|
return err
|
|
}
|
|
stats.PublishedReleaseAuthorCount = count
|
|
|
|
return nil
|
|
}
|
|
|
|
func releasesForActivityStatement(ctx context.Context, repoID int64, fromTime time.Time) db.Session {
|
|
return db.GetEngine(ctx).Where("`release`.repo_id = ?", repoID).
|
|
And("`release`.is_draft = ?", false).
|
|
And("`release`.published_unix >= ?", fromTime.Unix())
|
|
}
|