diff --git a/models/issues/issue.go b/models/issues/issue.go index 8dc0381e02..bf41a7ec28 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -8,10 +8,8 @@ import ( "context" "fmt" "regexp" - "sort" "code.gitea.io/gitea/models/db" - access_model "code.gitea.io/gitea/models/perm/access" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -212,17 +210,6 @@ func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) { return pr, err } -// LoadLabels loads labels -func (issue *Issue) LoadLabels(ctx context.Context) (err error) { - if issue.Labels == nil && issue.ID != 0 { - issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) - if err != nil { - return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) - } - } - return nil -} - // LoadPoster loads poster func (issue *Issue) LoadPoster(ctx context.Context) (err error) { if issue.Poster == nil && issue.PosterID != 0 { @@ -459,175 +446,6 @@ func (issue *Issue) IsPoster(uid int64) bool { return issue.OriginalAuthorID == 0 && issue.PosterID == uid } -func (issue *Issue) getLabels(ctx context.Context) (err error) { - if len(issue.Labels) > 0 { - return nil - } - - issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) - if err != nil { - return fmt.Errorf("getLabelsByIssueID: %w", err) - } - return nil -} - -func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) { - if err = issue.getLabels(ctx); err != nil { - return fmt.Errorf("getLabels: %w", err) - } - - for i := range issue.Labels { - if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil { - return fmt.Errorf("removeLabel: %w", err) - } - } - - return nil -} - -// ClearIssueLabels removes all issue labels as the given user. -// Triggers appropriate WebHooks, if any. -func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err := issue.LoadRepo(ctx); err != nil { - return err - } else if err = issue.LoadPullRequest(ctx); err != nil { - return err - } - - perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) - if err != nil { - return err - } - if !perm.CanWriteIssuesOrPulls(issue.IsPull) { - return ErrRepoLabelNotExist{} - } - - if err = clearIssueLabels(ctx, issue, doer); err != nil { - return err - } - - if err = committer.Commit(); err != nil { - return fmt.Errorf("Commit: %w", err) - } - - return nil -} - -type labelSorter []*Label - -func (ts labelSorter) Len() int { - return len([]*Label(ts)) -} - -func (ts labelSorter) Less(i, j int) bool { - return []*Label(ts)[i].ID < []*Label(ts)[j].ID -} - -func (ts labelSorter) Swap(i, j int) { - []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] -} - -// Ensure only one label of a given scope exists, with labels at the end of the -// array getting preference over earlier ones. -func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { - validLabels := make([]*Label, 0, len(labels)) - - for i, label := range labels { - scope := label.ExclusiveScope() - if scope != "" { - foundOther := false - for _, otherLabel := range labels[i+1:] { - if otherLabel.ExclusiveScope() == scope { - foundOther = true - break - } - } - if foundOther { - continue - } - } - validLabels = append(validLabels, label) - } - - return validLabels -} - -// ReplaceIssueLabels removes all current labels and add new labels to the issue. -// Triggers appropriate WebHooks, if any. -func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = issue.LoadRepo(ctx); err != nil { - return err - } - - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - labels = RemoveDuplicateExclusiveLabels(labels) - - sort.Sort(labelSorter(labels)) - sort.Sort(labelSorter(issue.Labels)) - - var toAdd, toRemove []*Label - - addIndex, removeIndex := 0, 0 - for addIndex < len(labels) && removeIndex < len(issue.Labels) { - addLabel := labels[addIndex] - removeLabel := issue.Labels[removeIndex] - if addLabel.ID == removeLabel.ID { - // Silently drop invalid labels - if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID { - toRemove = append(toRemove, removeLabel) - } - - addIndex++ - removeIndex++ - } else if addLabel.ID < removeLabel.ID { - // Only add if the label is valid - if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID { - toAdd = append(toAdd, addLabel) - } - addIndex++ - } else { - toRemove = append(toRemove, removeLabel) - removeIndex++ - } - } - toAdd = append(toAdd, labels[addIndex:]...) - toRemove = append(toRemove, issue.Labels[removeIndex:]...) - - if len(toAdd) > 0 { - if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil { - return fmt.Errorf("addLabels: %w", err) - } - } - - for _, l := range toRemove { - if err = deleteIssueLabel(ctx, issue, l, doer); err != nil { - return fmt.Errorf("removeLabel: %w", err) - } - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - // GetTasks returns the amount of tasks in the issues content func (issue *Issue) GetTasks() int { return len(issueTasksPat.FindAllStringIndex(issue.Content, -1)) @@ -862,16 +680,6 @@ func (issue *Issue) GetExternalName() string { return issue.OriginalAuthor } // GetExternalID ExternalUserRemappable interface func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID } -// 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() -} - // HasOriginalAuthor returns if an issue was migrated and has an original author. func (issue *Issue) HasOriginalAuthor() bool { return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0 diff --git a/models/issues/issue_label.go b/models/issues/issue_label.go new file mode 100644 index 0000000000..f4060b1402 --- /dev/null +++ b/models/issues/issue_label.go @@ -0,0 +1,490 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "fmt" + "sort" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/builder" +) + +// IssueLabel represents an issue-label relation. +type IssueLabel struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"UNIQUE(s)"` + LabelID int64 `xorm:"UNIQUE(s)"` +} + +// HasIssueLabel returns true if issue has been labeled. +func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool { + has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel)) + return has +} + +// newIssueLabel this function creates a new label it does not check if the label is valid for the issue +// YOU MUST CHECK THIS BEFORE THIS FUNCTION +func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + if err = db.Insert(ctx, &IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } + + if err = issue.LoadRepo(ctx); err != nil { + return + } + + opts := &CreateCommentOptions{ + Type: CommentTypeLabel, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Label: label, + Content: "1", + } + if _, err = CreateComment(ctx, opts); err != nil { + return err + } + + return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") +} + +// Remove all issue labels in the given exclusive scope +func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + scope := label.ExclusiveScope() + if scope == "" { + return nil + } + + var toRemove []*Label + for _, issueLabel := range issue.Labels { + if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { + toRemove = append(toRemove, issueLabel) + } + } + + for _, issueLabel := range toRemove { + if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { + return err + } + } + + return nil +} + +// NewIssueLabel creates a new issue-label relation. +func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { + if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { + return nil + } + + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = issue.LoadRepo(ctx); err != nil { + return err + } + + // Do NOT add invalid labels + if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID { + return nil + } + + if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { + return nil + } + + if err = newIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue +func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) { + if err = issue.LoadRepo(ctx); err != nil { + return err + } + for _, l := range labels { + // Don't add already present labels and invalid labels + if HasIssueLabel(ctx, issue.ID, l.ID) || + (l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) { + continue + } + + if err = newIssueLabel(ctx, issue, l, doer); err != nil { + return fmt.Errorf("newIssueLabel: %w", err) + } + } + + return nil +} + +// NewIssueLabels creates a list of issue-label relations. +func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = newIssueLabels(ctx, issue, labels, doer); err != nil { + return err + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} + +func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { + if count, err := db.DeleteByBean(ctx, &IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } else if count == 0 { + return nil + } + + if err = issue.LoadRepo(ctx); err != nil { + return + } + + opts := &CreateCommentOptions{ + Type: CommentTypeLabel, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Label: label, + } + if _, err = CreateComment(ctx, opts); err != nil { + return err + } + + return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") +} + +// DeleteIssueLabel deletes issue-label relation. +func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error { + if err := deleteIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + + issue.Labels = nil + return issue.LoadLabels(ctx) +} + +// DeleteLabelsByRepoID deletes labels of some repository +func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error { + deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID}) + + if _, err := db.GetEngine(ctx).In("label_id", deleteCond). + Delete(&IssueLabel{}); err != nil { + return err + } + + _, err := db.DeleteByBean(ctx, &Label{RepoID: repoID}) + return err +} + +// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore +func CountOrphanedLabels(ctx context.Context) (int64, error) { + noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count() + if err != nil { + return 0, err + } + + norepo, err := db.GetEngine(ctx).Table("label"). + Where(builder.And( + builder.Gt{"repo_id": 0}, + builder.NotIn("repo_id", builder.Select("id").From("`repository`")), + )). + Count() + if err != nil { + return 0, err + } + + noorg, err := db.GetEngine(ctx).Table("label"). + Where(builder.And( + builder.Gt{"org_id": 0}, + builder.NotIn("org_id", builder.Select("id").From("`user`")), + )). + Count() + if err != nil { + return 0, err + } + + return noref + norepo + noorg, nil +} + +// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore +func DeleteOrphanedLabels(ctx context.Context) error { + // delete labels with no reference + if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil { + return err + } + + // delete labels with none existing repos + if _, err := db.GetEngine(ctx). + Where(builder.And( + builder.Gt{"repo_id": 0}, + builder.NotIn("repo_id", builder.Select("id").From("`repository`")), + )). + Delete(Label{}); err != nil { + return err + } + + // delete labels with none existing orgs + if _, err := db.GetEngine(ctx). + Where(builder.And( + builder.Gt{"org_id": 0}, + builder.NotIn("org_id", builder.Select("id").From("`user`")), + )). + Delete(Label{}); err != nil { + return err + } + + return nil +} + +// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore +func CountOrphanedIssueLabels(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Table("issue_label"). + NotIn("label_id", builder.Select("id").From("label")). + Count() +} + +// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore +func DeleteOrphanedIssueLabels(ctx context.Context) error { + _, err := db.GetEngine(ctx). + NotIn("label_id", builder.Select("id").From("label")). + Delete(IssueLabel{}) + return err +} + +// CountIssueLabelWithOutsideLabels count label comments with outside label +func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")). + Table("issue_label"). + Join("inner", "label", "issue_label.label_id = label.id "). + Join("inner", "issue", "issue.id = issue_label.issue_id "). + Join("inner", "repository", "issue.repo_id = repository.id"). + Count(new(IssueLabel)) +} + +// FixIssueLabelWithOutsideLabels fix label comments with outside label +func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { + res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN ( + SELECT il_too.id FROM ( + SELECT il_too_too.id + FROM issue_label AS il_too_too + INNER JOIN label ON il_too_too.label_id = label.id + INNER JOIN issue on issue.id = il_too_too.issue_id + INNER JOIN repository on repository.id = issue.repo_id + WHERE + (label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id) + ) AS il_too )`) + if err != nil { + return 0, err + } + + return res.RowsAffected() +} + +// LoadLabels loads labels +func (issue *Issue) LoadLabels(ctx context.Context) (err error) { + if issue.Labels == nil && issue.ID != 0 { + issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err) + } + } + return nil +} + +// GetLabelsByIssueID returns all labels that belong to given issue by ID. +func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) { + var labels []*Label + return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID). + Join("LEFT", "issue_label", "issue_label.label_id = label.id"). + Asc("label.name"). + Find(&labels) +} + +func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) { + if err = issue.LoadLabels(ctx); err != nil { + return fmt.Errorf("getLabels: %w", err) + } + + for i := range issue.Labels { + if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil { + return fmt.Errorf("removeLabel: %w", err) + } + } + + return nil +} + +// ClearIssueLabels removes all issue labels as the given user. +// Triggers appropriate WebHooks, if any. +func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err := issue.LoadRepo(ctx); err != nil { + return err + } else if err = issue.LoadPullRequest(ctx); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.CanWriteIssuesOrPulls(issue.IsPull) { + return ErrRepoLabelNotExist{} + } + + if err = clearIssueLabels(ctx, issue, doer); err != nil { + return err + } + + if err = committer.Commit(); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + return nil +} + +type labelSorter []*Label + +func (ts labelSorter) Len() int { + return len([]*Label(ts)) +} + +func (ts labelSorter) Less(i, j int) bool { + return []*Label(ts)[i].ID < []*Label(ts)[j].ID +} + +func (ts labelSorter) Swap(i, j int) { + []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i] +} + +// Ensure only one label of a given scope exists, with labels at the end of the +// array getting preference over earlier ones. +func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label { + validLabels := make([]*Label, 0, len(labels)) + + for i, label := range labels { + scope := label.ExclusiveScope() + if scope != "" { + foundOther := false + for _, otherLabel := range labels[i+1:] { + if otherLabel.ExclusiveScope() == scope { + foundOther = true + break + } + } + if foundOther { + continue + } + } + validLabels = append(validLabels, label) + } + + return validLabels +} + +// ReplaceIssueLabels removes all current labels and add new labels to the issue. +// Triggers appropriate WebHooks, if any. +func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return err + } + defer committer.Close() + + if err = issue.LoadRepo(ctx); err != nil { + return err + } + + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + labels = RemoveDuplicateExclusiveLabels(labels) + + sort.Sort(labelSorter(labels)) + sort.Sort(labelSorter(issue.Labels)) + + var toAdd, toRemove []*Label + + addIndex, removeIndex := 0, 0 + for addIndex < len(labels) && removeIndex < len(issue.Labels) { + addLabel := labels[addIndex] + removeLabel := issue.Labels[removeIndex] + if addLabel.ID == removeLabel.ID { + // Silently drop invalid labels + if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID { + toRemove = append(toRemove, removeLabel) + } + + addIndex++ + removeIndex++ + } else if addLabel.ID < removeLabel.ID { + // Only add if the label is valid + if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID { + toAdd = append(toAdd, addLabel) + } + addIndex++ + } else { + toRemove = append(toRemove, removeLabel) + removeIndex++ + } + } + toAdd = append(toAdd, labels[addIndex:]...) + toRemove = append(toRemove, issue.Labels[removeIndex:]...) + + if len(toAdd) > 0 { + if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil { + return fmt.Errorf("addLabels: %w", err) + } + } + + for _, l := range toRemove { + if err = deleteIssueLabel(ctx, issue, l, doer); err != nil { + return fmt.Errorf("removeLabel: %w", err) + } + } + + issue.Labels = nil + if err = issue.LoadLabels(ctx); err != nil { + return err + } + + return committer.Commit() +} diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index e01070ecae..9fd13f0995 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -22,7 +22,7 @@ import ( // IssuesOptions represents options of an issue. type IssuesOptions struct { //nolint db.ListOptions - RepoID int64 // overwrites RepoCond if not 0 + RepoIDs []int64 // overwrites RepoCond if the length is not 0 RepoCond builder.Cond AssigneeID int64 PosterID int64 @@ -155,17 +155,24 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Sess return sess } +func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { + if len(opts.RepoIDs) == 1 { + opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]} + } else if len(opts.RepoIDs) > 1 { + opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs) + } + if opts.RepoCond != nil { + sess.And(opts.RepoCond) + } + return sess +} + func applyConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session { if len(opts.IssueIDs) > 0 { sess.In("issue.id", opts.IssueIDs) } - if opts.RepoID != 0 { - opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoID} - } - if opts.RepoCond != nil { - sess.And(opts.RepoCond) - } + applyRepoConditions(sess, opts) if !opts.IsClosed.IsNone() { sess.And("issue.is_closed=?", opts.IsClosed.IsTrue()) @@ -400,31 +407,6 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Sess ) } -// 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 -} - // GetRepoIDsForIssuesOptions find all repo ids for the given options func GetRepoIDsForIssuesOptions(opts *IssuesOptions, user *user_model.User) ([]int64, error) { repoIDs := make([]int64, 0, 5) @@ -453,351 +435,18 @@ func Issues(ctx context.Context, opts *IssuesOptions) ([]*Issue, error) { applyConditions(sess, opts) applySorts(sess, opts.SortType, opts.PriorityRepoID) - issues := make([]*Issue, 0, opts.ListOptions.PageSize) + issues := make(IssueList, 0, opts.ListOptions.PageSize) if err := sess.Find(&issues); err != nil { return nil, fmt.Errorf("unable to query Issues: %w", err) } - if err := IssueList(issues).LoadAttributes(); err != nil { + if err := issues.LoadAttributes(); err != nil { return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err) } return issues, 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() -} - -// 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 -) - -// 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{} - - countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session { - sess := db.GetEngine(db.DefaultContext). - Where("issue.repo_id = ?", opts.RepoID) - - if len(issueIDs) > 0 { - sess.In("issue.id", issueIDs) - } - - applyLabelsCondition(sess, opts) - - applyMilestoneCondition(sess, opts) - - if opts.ProjectID > 0 { - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - And("project_issue.project_id=?", opts.ProjectID) - } - - if opts.AssigneeID > 0 { - applyAssigneeCondition(sess, opts.AssigneeID) - } else if opts.AssigneeID == db.NoConditionID { - sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)") - } - - if opts.PosterID > 0 { - applyPosterCondition(sess, opts.PosterID) - } - - if opts.MentionedID > 0 { - applyMentionedCondition(sess, opts.MentionedID) - } - - if opts.ReviewRequestedID > 0 { - applyReviewRequestedCondition(sess, opts.ReviewRequestedID) - } - - if opts.ReviewedID > 0 { - applyReviewedCondition(sess, opts.ReviewedID) - } - - switch opts.IsPull { - case util.OptionalBoolTrue: - sess.And("issue.is_pull=?", true) - case util.OptionalBoolFalse: - sess.And("issue.is_pull=?", false) - } - - return sess - } - - var err error - stats.OpenCount, err = countSession(opts, issueIDs). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return stats, err - } - stats.ClosedCount, err = countSession(opts, issueIDs). - And("issue.is_closed = ?", true). - Count(new(Issue)) - return stats, err -} - -// UserIssueStatsOptions contains parameters accepted by GetUserIssueStats. -type UserIssueStatsOptions struct { - UserID int64 - RepoIDs []int64 - FilterMode int - IsPull bool - IsClosed bool - IssueIDs []int64 - IsArchived util.OptionalBool - LabelIDs []int64 - RepoCond builder.Cond - Org *organization.Organization - Team *organization.Team -} - -// GetUserIssueStats returns issue statistic information for dashboard by given conditions. -func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) { - var err error - stats := &IssueStats{} - - cond := builder.NewCond() - cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull}) - 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.UserID > 0 { - cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.UserID, opts.Org, opts.Team, opts.IsPull)) - } - - sess := func(cond builder.Cond) *xorm.Session { - s := db.GetEngine(db.DefaultContext).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.UserID > 0 || opts.IsArchived != util.OptionalBoolNone { - s.Join("INNER", "repository", "issue.repo_id = repository.id") - if opts.IsArchived != util.OptionalBoolNone { - s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()}) - } - } - return s - } - - switch opts.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.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeCreate: - stats.OpenCount, err = applyPosterCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeMention: - stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeReviewRequested: - stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - case FilterModeReviewed: - stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", false). - Count(new(Issue)) - if err != nil { - return nil, err - } - stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.UserID). - And("issue.is_closed = ?", true). - Count(new(Issue)) - if err != nil { - return nil, err - } - } - - cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed}) - stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.CreateCount, err = applyPosterCondition(sess(cond), opts.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.UserID).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.UserID).Count(new(Issue)) - if err != nil { - return nil, err - } - - stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.UserID).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 -} - // SearchIssueIDsByKeyword search issues on database func SearchIssueIDsByKeyword(ctx context.Context, kw string, repoIDs []int64, limit, start int) (int64, []int64, error) { repoCond := builder.In("repo_id", repoIDs) diff --git a/models/issues/issue_stats.go b/models/issues/issue_stats.go new file mode 100644 index 0000000000..9b9562ebdd --- /dev/null +++ b/models/issues/issue_stats.go @@ -0,0 +1,383 @@ +// 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{} + + countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session { + sess := db.GetEngine(db.DefaultContext). + Join("INNER", "repository", "`issue`.repo_id = `repository`.id") + 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]) + } + + if len(issueIDs) > 0 { + sess.In("issue.id", issueIDs) + } + + applyLabelsCondition(sess, opts) + + applyMilestoneCondition(sess, opts) + + if opts.ProjectID > 0 { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + And("project_issue.project_id=?", opts.ProjectID) + } + + if opts.AssigneeID > 0 { + applyAssigneeCondition(sess, opts.AssigneeID) + } else if opts.AssigneeID == db.NoConditionID { + sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)") + } + + if opts.PosterID > 0 { + applyPosterCondition(sess, opts.PosterID) + } + + if opts.MentionedID > 0 { + applyMentionedCondition(sess, opts.MentionedID) + } + + if opts.ReviewRequestedID > 0 { + applyReviewRequestedCondition(sess, opts.ReviewRequestedID) + } + + if opts.ReviewedID > 0 { + applyReviewedCondition(sess, opts.ReviewedID) + } + + switch opts.IsPull { + case util.OptionalBoolTrue: + sess.And("issue.is_pull=?", true) + case util.OptionalBoolFalse: + sess.And("issue.is_pull=?", false) + } + + return sess + } + + var err error + stats.OpenCount, err = countSession(opts, issueIDs). + And("issue.is_closed = ?", false). + Count(new(Issue)) + if err != nil { + return stats, err + } + stats.ClosedCount, err = countSession(opts, issueIDs). + And("issue.is_closed = ?", true). + Count(new(Issue)) + return stats, err +} + +// 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() +} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 5bf2f819be..80699a57b4 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -17,6 +17,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "xorm.io/builder" @@ -204,14 +205,16 @@ func TestIssues(t *testing.T) { func TestGetUserIssueStats(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) for _, test := range []struct { - Opts issues_model.UserIssueStatsOptions + FilterMode int + Opts issues_model.IssuesOptions ExpectedIssueStats issues_model.IssueStats }{ { - issues_model.UserIssueStatsOptions{ - UserID: 1, - RepoIDs: []int64{1}, - FilterMode: issues_model.FilterModeAll, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + RepoIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -222,11 +225,12 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - RepoIDs: []int64{1}, - FilterMode: issues_model.FilterModeAll, - IsClosed: true, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + RepoIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, + IsClosed: util.OptionalBoolTrue, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -237,9 +241,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeAssign, + issues_model.FilterModeAssign, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -250,9 +255,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeCreate, + issues_model.FilterModeCreate, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -263,9 +269,10 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeMention, + issues_model.FilterModeMention, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 6 @@ -277,10 +284,11 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 1, - FilterMode: issues_model.FilterModeCreate, - IssueIDs: []int64{1}, + issues_model.FilterModeCreate, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), + IssueIDs: []int64{1}, + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 1, // 1 @@ -291,11 +299,12 @@ func TestGetUserIssueStats(t *testing.T) { }, }, { - issues_model.UserIssueStatsOptions{ - UserID: 2, - Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}), - Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}), - FilterMode: issues_model.FilterModeAll, + issues_model.FilterModeAll, + issues_model.IssuesOptions{ + User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), + Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}), + Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}), + IsPull: util.OptionalBoolFalse, }, issues_model.IssueStats{ YourRepositoriesCount: 2, @@ -306,7 +315,7 @@ func TestGetUserIssueStats(t *testing.T) { }, } { t.Run(fmt.Sprintf("%#v", test.Opts), func(t *testing.T) { - stats, err := issues_model.GetUserIssueStats(test.Opts) + stats, err := issues_model.GetUserIssueStats(test.FilterMode, test.Opts) if !assert.NoError(t, err) { return } @@ -495,7 +504,7 @@ func TestCorrectIssueStats(t *testing.T) { // Now we will call the GetIssueStats with these IDs and if working, // get the correct stats back. issueStats, err := issues_model.GetIssueStats(&issues_model.IssuesOptions{ - RepoID: 1, + RepoIDs: []int64{1}, IssueIDs: ids, }) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index bebd5f4cd6..b6fd720fe5 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -81,7 +81,7 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } // Update issue count of labels - if err := issue.getLabels(ctx); err != nil { + if err := issue.LoadLabels(ctx); err != nil { return nil, err } for idx := range issue.Labels { diff --git a/models/issues/label.go b/models/issues/label.go index 9c22dcdd2d..8f2cf05a28 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -11,7 +11,6 @@ import ( "strings" "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -113,7 +112,7 @@ func (l *Label) CalOpenIssues() { // CalOpenOrgIssues calculates the open issues of a label for a specific repo func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{ - RepoID: repoID, + RepoIDs: []int64{repoID}, LabelIDs: []int64{labelID}, IsClosed: util.OptionalBoolFalse, }) @@ -282,13 +281,6 @@ func GetLabelsByIDs(labelIDs []int64) ([]*Label, error) { Find(&labels) } -// __________ .__ __ -// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__. -// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | | -// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ | -// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____| -// \/ \/|__| \/ \/ - // GetLabelInRepoByName returns a label by name in given repository. func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (*Label, error) { if len(labelName) == 0 || repoID <= 0 { @@ -393,13 +385,6 @@ func CountLabelsByRepoID(repoID int64) (int64, error) { return db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID).Count(&Label{}) } -// ________ -// \_____ \_______ ____ -// / | \_ __ \/ ___\ -// / | \ | \/ /_/ > -// \_______ /__| \___ / -// \/ /_____/ - // GetLabelInOrgByName returns a label by name in given organization. func GetLabelInOrgByName(ctx context.Context, orgID int64, labelName string) (*Label, error) { if len(labelName) == 0 || orgID <= 0 { @@ -496,22 +481,6 @@ func CountLabelsByOrgID(orgID int64) (int64, error) { return db.GetEngine(db.DefaultContext).Where("org_id = ?", orgID).Count(&Label{}) } -// .___ -// | | ______ ________ __ ____ -// | |/ ___// ___/ | \_/ __ \ -// | |\___ \ \___ \| | /\ ___/ -// |___/____ >____ >____/ \___ | -// \/ \/ \/ - -// GetLabelsByIssueID returns all labels that belong to given issue by ID. -func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) { - var labels []*Label - return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID). - Join("LEFT", "issue_label", "issue_label.label_id = label.id"). - Asc("label.name"). - Find(&labels) -} - func updateLabelCols(ctx context.Context, l *Label, cols ...string) error { _, err := db.GetEngine(ctx).ID(l.ID). SetExpr("num_issues", @@ -529,307 +498,3 @@ func updateLabelCols(ctx context.Context, l *Label, cols ...string) error { Cols(cols...).Update(l) return err } - -// .___ .____ ___. .__ -// | | ______ ________ __ ____ | | _____ \_ |__ ____ | | -// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| | -// | |\___ \ \___ \| | /\ ___/| |___ / __ \| \_\ \ ___/| |__ -// |___/____ >____ >____/ \___ >_______ (____ /___ /\___ >____/ -// \/ \/ \/ \/ \/ \/ \/ - -// IssueLabel represents an issue-label relation. -type IssueLabel struct { - ID int64 `xorm:"pk autoincr"` - IssueID int64 `xorm:"UNIQUE(s)"` - LabelID int64 `xorm:"UNIQUE(s)"` -} - -// HasIssueLabel returns true if issue has been labeled. -func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool { - has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel)) - return has -} - -// newIssueLabel this function creates a new label it does not check if the label is valid for the issue -// YOU MUST CHECK THIS BEFORE THIS FUNCTION -func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - if err = db.Insert(ctx, &IssueLabel{ - IssueID: issue.ID, - LabelID: label.ID, - }); err != nil { - return err - } - - if err = issue.LoadRepo(ctx); err != nil { - return - } - - opts := &CreateCommentOptions{ - Type: CommentTypeLabel, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - Label: label, - Content: "1", - } - if _, err = CreateComment(ctx, opts); err != nil { - return err - } - - return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") -} - -// Remove all issue labels in the given exclusive scope -func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - scope := label.ExclusiveScope() - if scope == "" { - return nil - } - - var toRemove []*Label - for _, issueLabel := range issue.Labels { - if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope { - toRemove = append(toRemove, issueLabel) - } - } - - for _, issueLabel := range toRemove { - if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil { - return err - } - } - - return nil -} - -// NewIssueLabel creates a new issue-label relation. -func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) { - if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) { - return nil - } - - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = issue.LoadRepo(ctx); err != nil { - return err - } - - // Do NOT add invalid labels - if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID { - return nil - } - - if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil { - return nil - } - - if err = newIssueLabel(ctx, issue, label, doer); err != nil { - return err - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - -// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue -func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) { - if err = issue.LoadRepo(ctx); err != nil { - return err - } - for _, l := range labels { - // Don't add already present labels and invalid labels - if HasIssueLabel(ctx, issue.ID, l.ID) || - (l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) { - continue - } - - if err = newIssueLabel(ctx, issue, l, doer); err != nil { - return fmt.Errorf("newIssueLabel: %w", err) - } - } - - return nil -} - -// NewIssueLabels creates a list of issue-label relations. -func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) - if err != nil { - return err - } - defer committer.Close() - - if err = newIssueLabels(ctx, issue, labels, doer); err != nil { - return err - } - - issue.Labels = nil - if err = issue.LoadLabels(ctx); err != nil { - return err - } - - return committer.Commit() -} - -func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) { - if count, err := db.DeleteByBean(ctx, &IssueLabel{ - IssueID: issue.ID, - LabelID: label.ID, - }); err != nil { - return err - } else if count == 0 { - return nil - } - - if err = issue.LoadRepo(ctx); err != nil { - return - } - - opts := &CreateCommentOptions{ - Type: CommentTypeLabel, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - Label: label, - } - if _, err = CreateComment(ctx, opts); err != nil { - return err - } - - return updateLabelCols(ctx, label, "num_issues", "num_closed_issue") -} - -// DeleteIssueLabel deletes issue-label relation. -func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error { - if err := deleteIssueLabel(ctx, issue, label, doer); err != nil { - return err - } - - issue.Labels = nil - return issue.LoadLabels(ctx) -} - -// DeleteLabelsByRepoID deletes labels of some repository -func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error { - deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID}) - - if _, err := db.GetEngine(ctx).In("label_id", deleteCond). - Delete(&IssueLabel{}); err != nil { - return err - } - - _, err := db.DeleteByBean(ctx, &Label{RepoID: repoID}) - return err -} - -// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore -func CountOrphanedLabels(ctx context.Context) (int64, error) { - noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count() - if err != nil { - return 0, err - } - - norepo, err := db.GetEngine(ctx).Table("label"). - Where(builder.And( - builder.Gt{"repo_id": 0}, - builder.NotIn("repo_id", builder.Select("id").From("`repository`")), - )). - Count() - if err != nil { - return 0, err - } - - noorg, err := db.GetEngine(ctx).Table("label"). - Where(builder.And( - builder.Gt{"org_id": 0}, - builder.NotIn("org_id", builder.Select("id").From("`user`")), - )). - Count() - if err != nil { - return 0, err - } - - return noref + norepo + noorg, nil -} - -// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore -func DeleteOrphanedLabels(ctx context.Context) error { - // delete labels with no reference - if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil { - return err - } - - // delete labels with none existing repos - if _, err := db.GetEngine(ctx). - Where(builder.And( - builder.Gt{"repo_id": 0}, - builder.NotIn("repo_id", builder.Select("id").From("`repository`")), - )). - Delete(Label{}); err != nil { - return err - } - - // delete labels with none existing orgs - if _, err := db.GetEngine(ctx). - Where(builder.And( - builder.Gt{"org_id": 0}, - builder.NotIn("org_id", builder.Select("id").From("`user`")), - )). - Delete(Label{}); err != nil { - return err - } - - return nil -} - -// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore -func CountOrphanedIssueLabels(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Table("issue_label"). - NotIn("label_id", builder.Select("id").From("label")). - Count() -} - -// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore -func DeleteOrphanedIssueLabels(ctx context.Context) error { - _, err := db.GetEngine(ctx). - NotIn("label_id", builder.Select("id").From("label")). - Delete(IssueLabel{}) - return err -} - -// CountIssueLabelWithOutsideLabels count label comments with outside label -func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")). - Table("issue_label"). - Join("inner", "label", "issue_label.label_id = label.id "). - Join("inner", "issue", "issue.id = issue_label.issue_id "). - Join("inner", "repository", "issue.repo_id = repository.id"). - Count(new(IssueLabel)) -} - -// FixIssueLabelWithOutsideLabels fix label comments with outside label -func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) { - res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN ( - SELECT il_too.id FROM ( - SELECT il_too_too.id - FROM issue_label AS il_too_too - INNER JOIN label ON il_too_too.label_id = label.id - INNER JOIN issue on issue.id = il_too_too.issue_id - INNER JOIN repository on repository.id = issue.repo_id - WHERE - (label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id) - ) AS il_too )`) - if err != nil { - return 0, err - } - - return res.RowsAffected() -} diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index e88b1b2bef..76ff80ffca 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -302,7 +302,7 @@ func populateIssueIndexer(ctx context.Context) { // UpdateRepoIndexer add/update all issues of the repositories func UpdateRepoIndexer(ctx context.Context, repo *repo_model.Repository) { is, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, IsClosed: util.OptionalBoolNone, IsPull: util.OptionalBoolNone, }) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 5bf5fc8c8b..95528d664d 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -470,7 +470,7 @@ func ListIssues(ctx *context.APIContext) { if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { issuesOpt := &issues_model.IssuesOptions{ ListOptions: listOptions, - RepoID: ctx.Repo.Repository.ID, + RepoIDs: []int64{ctx.Repo.Repository.ID}, IsClosed: isClosed, IssueIDs: issueIDs, LabelIDs: labelIDs, diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index cb0aaa3db5..88d2a97a7a 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -207,7 +207,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti issueStats = &issues_model.IssueStats{} } else { issueStats, err = issues_model.GetIssueStats(&issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, LabelIDs: labelIDs, MilestoneIDs: []int64{milestoneID}, ProjectID: projectID, @@ -258,7 +258,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti Page: pager.Paginater.Current(), PageSize: setting.UI.IssuePagingNum, }, - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, AssigneeID: assigneeID, PosterID: posterID, MentionedID: mentionedID, @@ -2652,7 +2652,7 @@ func ListIssues(ctx *context.Context) { if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { issuesOpt := &issues_model.IssuesOptions{ ListOptions: listOptions, - RepoID: ctx.Repo.Repository.ID, + RepoIDs: []int64{ctx.Repo.Repository.ID}, IsClosed: isClosed, IssueIDs: issueIDs, LabelIDs: labelIDs, diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 1af56e24b0..2513fc9a98 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -521,10 +521,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Parse ctx.FormString("repos") and remember matched repo IDs for later. // Gets set when clicking filters on the issues overview page. - repoIDs := getRepoIDs(ctx.FormString("repos")) - if len(repoIDs) > 0 { - opts.RepoCond = builder.In("issue.repo_id", repoIDs) - } + opts.RepoIDs = getRepoIDs(ctx.FormString("repos")) // ------------------------------ // Get issues as defined by opts. @@ -580,11 +577,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // ------------------------------- var issueStats *issues_model.IssueStats if !forceEmpty { - statsOpts := issues_model.UserIssueStatsOptions{ - UserID: ctx.Doer.ID, - FilterMode: filterMode, - IsPull: isPullList, - IsClosed: isShowClosed, + statsOpts := issues_model.IssuesOptions{ + User: ctx.Doer, + IsPull: util.OptionalBoolOf(isPullList), + IsClosed: util.OptionalBoolOf(isShowClosed), IssueIDs: issueIDsFromSearch, IsArchived: util.OptionalBoolFalse, LabelIDs: opts.LabelIDs, @@ -593,7 +589,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { RepoCond: opts.RepoCond, } - issueStats, err = issues_model.GetUserIssueStats(statsOpts) + issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts) if err != nil { ctx.ServerError("GetUserIssueStats Shown", err) return @@ -609,9 +605,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } else { shownIssues = int(issueStats.ClosedCount) } - if len(repoIDs) != 0 { + if len(opts.RepoIDs) != 0 { shownIssues = 0 - for _, repoID := range repoIDs { + for _, repoID := range opts.RepoIDs { shownIssues += int(issueCountByRepo[repoID]) } } @@ -622,8 +618,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } ctx.Data["TotalIssueCount"] = allIssueCount - if len(repoIDs) == 1 { - repo := showReposMap[repoIDs[0]] + if len(opts.RepoIDs) == 1 { + repo := showReposMap[opts.RepoIDs[0]] if repo != nil { ctx.Data["SingleRepoLink"] = repo.Link() } @@ -665,7 +661,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["IssueStats"] = issueStats ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType - ctx.Data["RepoIDs"] = repoIDs + ctx.Data["RepoIDs"] = opts.RepoIDs ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["SelectLabels"] = selectedLabels @@ -676,7 +672,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } // Convert []int64 to string - reposParam, _ := json.Marshal(repoIDs) + reposParam, _ := json.Marshal(opts.RepoIDs) ctx.Data["ReposParam"] = string(reposParam) diff --git a/services/migrations/gitea_uploader_test.go b/services/migrations/gitea_uploader_test.go index b59ccb7c44..878b6d6b84 100644 --- a/services/migrations/gitea_uploader_test.go +++ b/services/migrations/gitea_uploader_test.go @@ -104,7 +104,7 @@ func TestGiteaUploadRepo(t *testing.T) { assert.Len(t, releases, 1) issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{ - RepoID: repo.ID, + RepoIDs: []int64{repo.ID}, IsPull: util.OptionalBoolFalse, SortType: "oldest", })