mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Some refactors for issues stats (#24793)
This PR - [x] Move some functions from `issues.go` to `issue_stats.go` and `issue_label.go` - [x] Remove duplicated issue options `UserIssueStatsOption` to keep only one `IssuesOptions`
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										490
									
								
								models/issues/issue_label.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										490
									
								
								models/issues/issue_label.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| } | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										383
									
								
								models/issues/issue_stats.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								models/issues/issue_stats.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| } | ||||
| @@ -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, | ||||
| 			issues_model.FilterModeAll, | ||||
| 			issues_model.IssuesOptions{ | ||||
| 				User:    unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), | ||||
| 				RepoIDs: []int64{1}, | ||||
| 				FilterMode: issues_model.FilterModeAll, | ||||
| 				IsPull:  util.OptionalBoolFalse, | ||||
| 			}, | ||||
| 			issues_model.IssueStats{ | ||||
| 				YourRepositoriesCount: 1, // 6 | ||||
| @@ -222,11 +225,12 @@ func TestGetUserIssueStats(t *testing.T) { | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			issues_model.UserIssueStatsOptions{ | ||||
| 				UserID:     1, | ||||
| 			issues_model.FilterModeAll, | ||||
| 			issues_model.IssuesOptions{ | ||||
| 				User:     unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), | ||||
| 				RepoIDs:  []int64{1}, | ||||
| 				FilterMode: issues_model.FilterModeAll, | ||||
| 				IsClosed:   true, | ||||
| 				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, | ||||
| 			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, | ||||
| 			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}), | ||||
| 				FilterMode: issues_model.FilterModeAll, | ||||
| 				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, | ||||
| 	}) | ||||
|  | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
| 	}) | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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", | ||||
| 	}) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user