2023-05-19 19:47:48 +05:30
|
|
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
package issues
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
|
|
"code.gitea.io/gitea/modules/util"
|
|
|
|
|
|
|
|
"xorm.io/builder"
|
|
|
|
"xorm.io/xorm"
|
|
|
|
)
|
|
|
|
|
|
|
|
// IssueStats represents issue statistic information.
|
|
|
|
type IssueStats struct {
|
|
|
|
OpenCount, ClosedCount int64
|
|
|
|
YourRepositoriesCount int64
|
|
|
|
AssignCount int64
|
|
|
|
CreateCount int64
|
|
|
|
MentionCount int64
|
|
|
|
ReviewRequestedCount int64
|
|
|
|
ReviewedCount int64
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter modes.
|
|
|
|
const (
|
|
|
|
FilterModeAll = iota
|
|
|
|
FilterModeAssign
|
|
|
|
FilterModeCreate
|
|
|
|
FilterModeMention
|
|
|
|
FilterModeReviewRequested
|
|
|
|
FilterModeReviewed
|
|
|
|
FilterModeYourRepositories
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// MaxQueryParameters represents the max query parameters
|
|
|
|
// When queries are broken down in parts because of the number
|
|
|
|
// of parameters, attempt to break by this amount
|
|
|
|
MaxQueryParameters = 300
|
|
|
|
)
|
|
|
|
|
|
|
|
// CountIssuesByRepo map from repoID to number of issues matching the options
|
|
|
|
func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) {
|
|
|
|
sess := db.GetEngine(ctx).
|
|
|
|
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
|
|
|
|
|
|
|
applyConditions(sess, opts)
|
|
|
|
|
|
|
|
countsSlice := make([]*struct {
|
|
|
|
RepoID int64
|
|
|
|
Count int64
|
|
|
|
}, 0, 10)
|
|
|
|
if err := sess.GroupBy("issue.repo_id").
|
|
|
|
Select("issue.repo_id AS repo_id, COUNT(*) AS count").
|
|
|
|
Table("issue").
|
|
|
|
Find(&countsSlice); err != nil {
|
|
|
|
return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
countMap := make(map[int64]int64, len(countsSlice))
|
|
|
|
for _, c := range countsSlice {
|
|
|
|
countMap[c.RepoID] = c.Count
|
|
|
|
}
|
|
|
|
return countMap, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// CountIssues number return of issues by given conditions.
|
|
|
|
func CountIssues(ctx context.Context, opts *IssuesOptions) (int64, error) {
|
|
|
|
sess := db.GetEngine(ctx).
|
|
|
|
Select("COUNT(issue.id) AS count").
|
|
|
|
Table("issue").
|
|
|
|
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
|
|
|
applyConditions(sess, opts)
|
|
|
|
|
|
|
|
return sess.Count()
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetIssueStats returns issue statistic information by given conditions.
|
|
|
|
func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
|
|
|
|
if len(opts.IssueIDs) <= MaxQueryParameters {
|
|
|
|
return getIssueStatsChunk(opts, opts.IssueIDs)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If too long a list of IDs is provided, we get the statistics in
|
|
|
|
// smaller chunks and get accumulates. Note: this could potentially
|
|
|
|
// get us invalid results. The alternative is to insert the list of
|
|
|
|
// ids in a temporary table and join from them.
|
|
|
|
accum := &IssueStats{}
|
|
|
|
for i := 0; i < len(opts.IssueIDs); {
|
|
|
|
chunk := i + MaxQueryParameters
|
|
|
|
if chunk > len(opts.IssueIDs) {
|
|
|
|
chunk = len(opts.IssueIDs)
|
|
|
|
}
|
|
|
|
stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk])
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
accum.OpenCount += stats.OpenCount
|
|
|
|
accum.ClosedCount += stats.ClosedCount
|
|
|
|
accum.YourRepositoriesCount += stats.YourRepositoriesCount
|
|
|
|
accum.AssignCount += stats.AssignCount
|
|
|
|
accum.CreateCount += stats.CreateCount
|
|
|
|
accum.OpenCount += stats.MentionCount
|
|
|
|
accum.ReviewRequestedCount += stats.ReviewRequestedCount
|
|
|
|
accum.ReviewedCount += stats.ReviewedCount
|
|
|
|
i = chunk
|
|
|
|
}
|
|
|
|
return accum, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
|
|
|
|
stats := &IssueStats{}
|
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
sess := db.GetEngine(db.DefaultContext).
|
|
|
|
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
var err error
|
|
|
|
stats.OpenCount, err = applyIssuesOptions(sess, opts, issueIDs).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return stats, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyIssuesOptions(sess, opts, issueIDs).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
return stats, err
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int64) *xorm.Session {
|
|
|
|
if len(opts.RepoIDs) > 1 {
|
|
|
|
sess.In("issue.repo_id", opts.RepoIDs)
|
|
|
|
} else if len(opts.RepoIDs) == 1 {
|
|
|
|
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if len(issueIDs) > 0 {
|
|
|
|
sess.In("issue.id", issueIDs)
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
applyLabelsCondition(sess, opts)
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
applyMilestoneCondition(sess, opts)
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
applyProjectCondition(sess, opts)
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if opts.AssigneeID > 0 {
|
|
|
|
applyAssigneeCondition(sess, opts.AssigneeID)
|
|
|
|
} else if opts.AssigneeID == db.NoConditionID {
|
|
|
|
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if opts.PosterID > 0 {
|
|
|
|
applyPosterCondition(sess, opts.PosterID)
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if opts.MentionedID > 0 {
|
|
|
|
applyMentionedCondition(sess, opts.MentionedID)
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if opts.ReviewRequestedID > 0 {
|
|
|
|
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
|
|
|
|
}
|
2023-05-19 19:47:48 +05:30
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
if opts.ReviewedID > 0 {
|
|
|
|
applyReviewedCondition(sess, opts.ReviewedID)
|
2023-05-19 19:47:48 +05:30
|
|
|
}
|
|
|
|
|
2023-08-23 06:59:49 +05:30
|
|
|
switch opts.IsPull {
|
|
|
|
case util.OptionalBoolTrue:
|
|
|
|
sess.And("issue.is_pull=?", true)
|
|
|
|
case util.OptionalBoolFalse:
|
|
|
|
sess.And("issue.is_pull=?", false)
|
2023-05-19 19:47:48 +05:30
|
|
|
}
|
2023-08-23 06:59:49 +05:30
|
|
|
|
|
|
|
return sess
|
2023-05-19 19:47:48 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
|
|
|
|
func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) {
|
|
|
|
if opts.User == nil {
|
|
|
|
return nil, errors.New("issue stats without user")
|
|
|
|
}
|
|
|
|
if opts.IsPull.IsNone() {
|
|
|
|
return nil, errors.New("unaccepted ispull option")
|
|
|
|
}
|
|
|
|
|
|
|
|
var err error
|
|
|
|
stats := &IssueStats{}
|
|
|
|
|
|
|
|
cond := builder.NewCond()
|
|
|
|
|
|
|
|
cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull.IsTrue()})
|
|
|
|
|
|
|
|
if len(opts.RepoIDs) > 0 {
|
|
|
|
cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs))
|
|
|
|
}
|
|
|
|
if len(opts.IssueIDs) > 0 {
|
|
|
|
cond = cond.And(builder.In("issue.id", opts.IssueIDs))
|
|
|
|
}
|
|
|
|
if opts.RepoCond != nil {
|
|
|
|
cond = cond.And(opts.RepoCond)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.User != nil {
|
|
|
|
cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.IsTrue()))
|
|
|
|
}
|
|
|
|
|
|
|
|
sess := func(cond builder.Cond) *xorm.Session {
|
|
|
|
s := db.GetEngine(db.DefaultContext).
|
|
|
|
Join("INNER", "repository", "`issue`.repo_id = `repository`.id").
|
|
|
|
Where(cond)
|
|
|
|
if len(opts.LabelIDs) > 0 {
|
|
|
|
s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id").
|
|
|
|
In("issue_label.label_id", opts.LabelIDs)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.IsArchived != util.OptionalBoolNone {
|
|
|
|
s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()})
|
|
|
|
}
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
|
|
|
switch filterMode {
|
|
|
|
case FilterModeAll, FilterModeYourRepositories:
|
|
|
|
stats.OpenCount, err = sess(cond).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = sess(cond).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
case FilterModeAssign:
|
|
|
|
stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
case FilterModeCreate:
|
|
|
|
stats.OpenCount, err = applyPosterCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
case FilterModeMention:
|
|
|
|
stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
case FilterModeReviewRequested:
|
|
|
|
stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
case FilterModeReviewed:
|
|
|
|
stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", false).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.User.ID).
|
|
|
|
And("issue.is_closed = ?", true).
|
|
|
|
Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed.IsTrue()})
|
|
|
|
stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
stats.CreateCount, err = applyPosterCondition(sess(cond), opts.User.ID).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.User.ID).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.User.ID).Count(new(Issue))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return stats, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetRepoIssueStats returns number of open and closed repository issues by given filter mode.
|
|
|
|
func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) {
|
|
|
|
countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session {
|
|
|
|
sess := db.GetEngine(db.DefaultContext).
|
|
|
|
Where("is_closed = ?", isClosed).
|
|
|
|
And("is_pull = ?", isPull).
|
|
|
|
And("repo_id = ?", repoID)
|
|
|
|
|
|
|
|
return sess
|
|
|
|
}
|
|
|
|
|
|
|
|
openCountSession := countSession(false, isPull, repoID)
|
|
|
|
closedCountSession := countSession(true, isPull, repoID)
|
|
|
|
|
|
|
|
switch filterMode {
|
|
|
|
case FilterModeAssign:
|
|
|
|
applyAssigneeCondition(openCountSession, uid)
|
|
|
|
applyAssigneeCondition(closedCountSession, uid)
|
|
|
|
case FilterModeCreate:
|
|
|
|
applyPosterCondition(openCountSession, uid)
|
|
|
|
applyPosterCondition(closedCountSession, uid)
|
|
|
|
}
|
|
|
|
|
|
|
|
openResult, _ := openCountSession.Count(new(Issue))
|
|
|
|
closedResult, _ := closedCountSession.Count(new(Issue))
|
|
|
|
|
|
|
|
return openResult, closedResult
|
|
|
|
}
|
|
|
|
|
|
|
|
// CountOrphanedIssues count issues without a repo
|
|
|
|
func CountOrphanedIssues(ctx context.Context) (int64, error) {
|
|
|
|
return db.GetEngine(ctx).
|
|
|
|
Table("issue").
|
|
|
|
Join("LEFT", "repository", "issue.repo_id=repository.id").
|
|
|
|
Where(builder.IsNull{"repository.id"}).
|
|
|
|
Select("COUNT(`issue`.`id`)").
|
|
|
|
Count()
|
|
|
|
}
|