初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// IssueAssignees saves all issue assignees
type IssueAssignees struct {
ID int64 `xorm:"pk autoincr"`
AssigneeID int64 `xorm:"INDEX"`
IssueID int64 `xorm:"INDEX"`
}
func init() {
db.RegisterModel(new(IssueAssignees))
}
// LoadAssignees load assignees of this issue.
func (issue *Issue) LoadAssignees(ctx context.Context) (err error) {
if issue.isAssigneeLoaded || len(issue.Assignees) > 0 {
return nil
}
// Reset maybe preexisting assignees
issue.Assignees = []*user_model.User{}
issue.Assignee = nil
if err = db.GetEngine(ctx).Table("`user`").
Join("INNER", "issue_assignees", "assignee_id = `user`.id").
Where("issue_assignees.issue_id = ?", issue.ID).
Find(&issue.Assignees); err != nil {
return err
}
issue.isAssigneeLoaded = true
// Check if we have at least one assignee and if yes put it in as `Assignee`
if len(issue.Assignees) > 0 {
issue.Assignee = issue.Assignees[0]
}
return nil
}
// GetAssigneeIDsByIssue returns the IDs of users assigned to an issue
// but skips joining with `user` for performance reasons.
// User permissions must be verified elsewhere if required.
func GetAssigneeIDsByIssue(ctx context.Context, issueID int64) ([]int64, error) {
userIDs := make([]int64, 0, 5)
return userIDs, db.GetEngine(ctx).
Table("issue_assignees").
Cols("assignee_id").
Where("issue_id = ?", issueID).
Distinct("assignee_id").
Find(&userIDs)
}
// IsUserAssignedToIssue returns true when the user is assigned to the issue
func IsUserAssignedToIssue(ctx context.Context, issue *Issue, user *user_model.User) (isAssigned bool, err error) {
return db.Exist[IssueAssignees](ctx, builder.Eq{"assignee_id": user.ID, "issue_id": issue.ID})
}
type AssignedIssuesOptions struct {
db.ListOptions
AssigneeID int64
RepoOwnerID int64
}
func (opts *AssignedIssuesOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.AssigneeID != 0 {
cond = cond.And(builder.In("issue.id", builder.Select("issue_id").From("issue_assignees").Where(builder.Eq{"assignee_id": opts.AssigneeID})))
}
if opts.RepoOwnerID != 0 {
cond = cond.And(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.RepoOwnerID})))
}
return cond
}
func GetAssignedIssues(ctx context.Context, opts *AssignedIssuesOptions) ([]*Issue, int64, error) {
return db.FindAndCount[Issue](ctx, opts)
}
// ToggleIssueAssignee changes a user between assigned and not assigned for this issue, and make issue comment for it.
func ToggleIssueAssignee(ctx context.Context, issue *Issue, doer *user_model.User, assigneeID int64) (removed bool, comment *Comment, err error) {
if err := db.WithTx(ctx, func(ctx context.Context) error {
removed, comment, err = toggleIssueAssignee(ctx, issue, doer, assigneeID, false)
return err
}); err != nil {
return false, nil, err
}
return removed, comment, nil
}
func toggleIssueAssignee(ctx context.Context, issue *Issue, doer *user_model.User, assigneeID int64, isCreate bool) (removed bool, comment *Comment, err error) {
removed, err = toggleUserAssignee(ctx, issue, assigneeID)
if err != nil {
return false, nil, fmt.Errorf("UpdateIssueUserByAssignee: %w", err)
}
// Repo infos
if err = issue.LoadRepo(ctx); err != nil {
return false, nil, fmt.Errorf("loadRepo: %w", err)
}
opts := &CreateCommentOptions{
Type: CommentTypeAssignees,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
RemovedAssignee: removed,
AssigneeID: assigneeID,
}
// Comment
comment, err = CreateComment(ctx, opts)
if err != nil {
return false, nil, fmt.Errorf("createComment: %w", err)
}
// if pull request is in the middle of creation - don't call webhook
if isCreate {
return removed, comment, err
}
return removed, comment, nil
}
// toggles user assignee state in database
func toggleUserAssignee(ctx context.Context, issue *Issue, assigneeID int64) (removed bool, err error) {
// Check if the user exists
assignee, err := user_model.GetUserByID(ctx, assigneeID)
if err != nil {
return false, err
}
// Check if the submitted user is already assigned, if yes delete him otherwise add him
found := false
i := 0
for ; i < len(issue.Assignees); i++ {
if issue.Assignees[i].ID == assigneeID {
found = true
break
}
}
assigneeIn := IssueAssignees{AssigneeID: assigneeID, IssueID: issue.ID}
if found {
issue.Assignees = append(issue.Assignees[:i], issue.Assignees[i+1:]...)
_, err = db.DeleteByBean(ctx, &assigneeIn)
if err != nil {
return found, err
}
} else {
issue.Assignees = append(issue.Assignees, assignee)
if err = db.Insert(ctx, &assigneeIn); err != nil {
return found, err
}
}
return found, nil
}
// MakeIDsFromAPIAssigneesToAdd returns an array with all assignee IDs
func MakeIDsFromAPIAssigneesToAdd(ctx context.Context, oneAssignee string, multipleAssignees []string) (assigneeIDs []int64, err error) {
var requestAssignees []string
// Keeping the old assigning method for compatibility reasons
if oneAssignee != "" && !util.SliceContainsString(multipleAssignees, oneAssignee) {
requestAssignees = append(requestAssignees, oneAssignee)
}
// Prevent empty assignees
if len(multipleAssignees) > 0 && multipleAssignees[0] != "" {
requestAssignees = append(requestAssignees, multipleAssignees...)
}
// Get the IDs of all assignees
assigneeIDs, err = user_model.GetUserIDsByNames(ctx, requestAssignees, false)
return assigneeIDs, err
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestUpdateAssignee(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Fake issue with assignees
issue, err := issues_model.GetIssueByID(t.Context(), 1)
assert.NoError(t, err)
err = issue.LoadAttributes(t.Context())
assert.NoError(t, err)
// Assign multiple users
user2, err := user_model.GetUserByID(t.Context(), 2)
assert.NoError(t, err)
_, _, err = issues_model.ToggleIssueAssignee(t.Context(), issue, &user_model.User{ID: 1}, user2.ID)
assert.NoError(t, err)
org3, err := user_model.GetUserByID(t.Context(), 3)
assert.NoError(t, err)
_, _, err = issues_model.ToggleIssueAssignee(t.Context(), issue, &user_model.User{ID: 1}, org3.ID)
assert.NoError(t, err)
user1, err := user_model.GetUserByID(t.Context(), 1) // This user is already assigned (see the definition in fixtures), so running UpdateAssignee should unassign him
assert.NoError(t, err)
_, _, err = issues_model.ToggleIssueAssignee(t.Context(), issue, &user_model.User{ID: 1}, user1.ID)
assert.NoError(t, err)
// Check if he got removed
isAssigned, err := issues_model.IsUserAssignedToIssue(t.Context(), issue, user1)
assert.NoError(t, err)
assert.False(t, isAssigned)
// Check if they're all there
err = issue.LoadAssignees(t.Context())
assert.NoError(t, err)
var expectedAssignees []*user_model.User
expectedAssignees = append(expectedAssignees, user2, org3)
for in, assignee := range issue.Assignees {
assert.Equal(t, assignee.ID, expectedAssignees[in].ID)
}
// Check if the user is assigned
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, user2)
assert.NoError(t, err)
assert.True(t, isAssigned)
// This user should not be assigned
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, &user_model.User{ID: 4})
assert.NoError(t, err)
assert.False(t, isAssigned)
}
func TestMakeIDsFromAPIAssigneesToAdd(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
_ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
IDs, err := issues_model.MakeIDsFromAPIAssigneesToAdd(t.Context(), "", []string{""})
assert.NoError(t, err)
assert.Equal(t, []int64{}, IDs)
_, err = issues_model.MakeIDsFromAPIAssigneesToAdd(t.Context(), "", []string{"none_existing_user"})
assert.Error(t, err)
IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(t.Context(), "user1", []string{"user1"})
assert.NoError(t, err)
assert.Equal(t, []int64{1}, IDs)
IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(t.Context(), "user2", []string{""})
assert.NoError(t, err)
assert.Equal(t, []int64{2}, IDs)
IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(t.Context(), "", []string{"user1", "user2"})
assert.NoError(t, err)
assert.Equal(t, []int64{1, 2}, IDs)
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"strconv"
"gitea.dev/models/db"
"gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown"
"xorm.io/builder"
)
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
type CodeComments map[string]map[int64][]*Comment
// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line
func FetchCodeComments(ctx context.Context, issue *Issue, currentUser *user_model.User, showOutdatedComments bool) (CodeComments, error) {
return fetchCodeCommentsByReview(ctx, issue, currentUser, nil, showOutdatedComments)
}
func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review, showOutdatedComments bool) (CodeComments, error) {
pathToLineToComment := make(CodeComments)
if review == nil {
review = &Review{ID: 0}
}
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
ReviewID: review.ID,
}
comments, err := findCodeComments(ctx, opts, issue, currentUser, review, showOutdatedComments)
if err != nil {
return nil, err
}
for _, comment := range comments {
if pathToLineToComment[comment.TreePath] == nil {
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment)
}
pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment)
}
return pathToLineToComment, nil
}
func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review, showOutdatedComments bool) ([]*Comment, error) {
var comments CommentList
if review == nil {
review = &Review{ID: 0}
}
conds := opts.ToConds()
if !showOutdatedComments && review.ID == 0 {
conds = conds.And(builder.Eq{"invalidated": false})
}
e := db.GetEngine(ctx)
if err := e.Where(conds).
Asc("comment.created_unix").
Asc("comment.id").
Find(&comments); err != nil {
return nil, err
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := comments.LoadPosters(ctx); err != nil {
return nil, err
}
if err := comments.LoadAttachments(ctx); err != nil {
return nil, err
}
// Find all reviews by ReviewID
reviews := make(map[int64]*Review)
ids := make([]int64, 0, len(comments))
for _, comment := range comments {
if comment.ReviewID != 0 {
ids = append(ids, comment.ReviewID)
}
}
if len(ids) > 0 {
if err := e.In("id", ids).Find(&reviews); err != nil {
return nil, err
}
}
n := 0
for _, comment := range comments {
if re, ok := reviews[comment.ReviewID]; ok && re != nil {
// If the review is pending only the author can see the comments (except if the review is set)
if review.ID == 0 && re.Type == ReviewTypePending &&
(currentUser == nil || currentUser.ID != re.ReviewerID) {
continue
}
comment.Review = re
comment.Issue = issue
}
comments[n] = comment
n++
if err := comment.LoadResolveDoer(ctx); err != nil {
return nil, err
}
if err := comment.LoadReactions(ctx, issue.Repo); err != nil {
return nil, err
}
var err error
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
if comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content); err != nil {
return nil, err
}
}
return comments[:n], nil
}
// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number
func FetchCodeCommentsByLine(ctx context.Context, issue *Issue, currentUser *user_model.User, treePath string, line int64, showOutdatedComments bool) (CommentList, error) {
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
TreePath: treePath,
Line: line,
}
return findCodeComments(ctx, opts, issue, currentUser, nil, showOutdatedComments)
}
+483
View File
@@ -0,0 +1,483 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
)
// CommentList defines a list of comments
type CommentList []*Comment
// LoadPosters loads posters
func (comments CommentList) LoadPosters(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
posterIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) {
return c.PosterID, c.Poster == nil && c.PosterID > 0
})
posterMaps, err := user_model.GetUsersMapByIDs(ctx, posterIDs)
if err != nil {
return err
}
for _, comment := range comments {
if comment.Poster == nil {
comment.Poster = user_model.GetPossibleUserFromMap(comment.PosterID, posterMaps)
}
}
return nil
}
func (comments CommentList) getLabelIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.LabelID, comment.LabelID > 0 && comment.Label == nil
})
}
func (comments CommentList) loadLabels(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
labelIDs := comments.getLabelIDs()
if len(labelIDs) == 0 {
return nil
}
commentLabels := make(map[int64]*Label, len(labelIDs))
left := len(labelIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("id", labelIDs[:limit]).
Rows(new(Label))
if err != nil {
return err
}
for rows.Next() {
var label Label
err = rows.Scan(&label)
if err != nil {
_ = rows.Close()
return err
}
commentLabels[label.ID] = &label
}
_ = rows.Close()
left -= limit
labelIDs = labelIDs[limit:]
}
for _, comment := range comments {
comment.Label = commentLabels[comment.ID]
}
return nil
}
func (comments CommentList) getMilestoneIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.MilestoneID, comment.MilestoneID > 0
})
}
func (comments CommentList) loadMilestones(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
milestoneIDs := comments.getMilestoneIDs()
if len(milestoneIDs) == 0 {
return nil
}
milestoneMaps := make(map[int64]*Milestone, len(milestoneIDs))
left := len(milestoneIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
err := db.GetEngine(ctx).
In("id", milestoneIDs[:limit]).
Find(&milestoneMaps)
if err != nil {
return err
}
left -= limit
milestoneIDs = milestoneIDs[limit:]
}
for _, comment := range comments {
comment.Milestone = milestoneMaps[comment.MilestoneID]
}
return nil
}
func (comments CommentList) getOldMilestoneIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.OldMilestoneID, comment.OldMilestoneID > 0
})
}
func (comments CommentList) loadOldMilestones(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
milestoneIDs := comments.getOldMilestoneIDs()
if len(milestoneIDs) == 0 {
return nil
}
milestoneMaps := make(map[int64]*Milestone, len(milestoneIDs))
left := len(milestoneIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
err := db.GetEngine(ctx).
In("id", milestoneIDs[:limit]).
Find(&milestoneMaps)
if err != nil {
return err
}
left -= limit
milestoneIDs = milestoneIDs[limit:]
}
for _, issue := range comments {
issue.OldMilestone = milestoneMaps[issue.MilestoneID]
}
return nil
}
func (comments CommentList) getAssigneeIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.AssigneeID, comment.AssigneeID > 0
})
}
func (comments CommentList) loadAssignees(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
assigneeIDs := comments.getAssigneeIDs()
if len(assigneeIDs) == 0 {
return nil
}
assignees := make(map[int64]*user_model.User, len(assigneeIDs))
left := len(assigneeIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("id", assigneeIDs[:limit]).
Rows(new(user_model.User))
if err != nil {
return err
}
for rows.Next() {
var user user_model.User
err = rows.Scan(&user)
if err != nil {
rows.Close()
return err
}
assignees[user.ID] = &user
}
_ = rows.Close()
left -= limit
assigneeIDs = assigneeIDs[limit:]
}
for _, comment := range comments {
comment.Assignee = assignees[comment.AssigneeID]
if comment.Assignee == nil {
comment.AssigneeID = user_model.GhostUserID
comment.Assignee = user_model.NewGhostUser()
}
}
return nil
}
// getIssueIDs returns all the issue ids on this comment list which issue hasn't been loaded
func (comments CommentList) getIssueIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.IssueID, comment.Issue == nil
})
}
// Issues returns all the issues of comments
func (comments CommentList) Issues() IssueList {
issues := make(map[int64]*Issue, len(comments))
for _, comment := range comments {
if comment.Issue != nil {
if _, ok := issues[comment.Issue.ID]; !ok {
issues[comment.Issue.ID] = comment.Issue
}
}
}
issueList := make([]*Issue, 0, len(issues))
for _, issue := range issues {
issueList = append(issueList, issue)
}
return issueList
}
// LoadIssues loads issues of comments
func (comments CommentList) LoadIssues(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
issueIDs := comments.getIssueIDs()
issues := make(map[int64]*Issue, len(issueIDs))
left := len(issueIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("id", issueIDs[:limit]).
Rows(new(Issue))
if err != nil {
return err
}
for rows.Next() {
var issue Issue
err = rows.Scan(&issue)
if err != nil {
rows.Close()
return err
}
issues[issue.ID] = &issue
}
_ = rows.Close()
left -= limit
issueIDs = issueIDs[limit:]
}
for _, comment := range comments {
if comment.Issue == nil {
comment.Issue = issues[comment.IssueID]
}
}
return nil
}
func (comments CommentList) getDependentIssueIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
if comment.DependentIssue != nil {
return 0, false
}
return comment.DependentIssueID, comment.DependentIssueID > 0
})
}
func (comments CommentList) loadDependentIssues(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
e := db.GetEngine(ctx)
issueIDs := comments.getDependentIssueIDs()
if len(issueIDs) == 0 {
return nil
}
issues := make(map[int64]*Issue, len(issueIDs))
left := len(issueIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := e.
In("id", issueIDs[:limit]).
Rows(new(Issue))
if err != nil {
return err
}
for rows.Next() {
var issue Issue
err = rows.Scan(&issue)
if err != nil {
_ = rows.Close()
return err
}
issues[issue.ID] = &issue
}
_ = rows.Close()
left -= limit
issueIDs = issueIDs[limit:]
}
for _, comment := range comments {
if comment.DependentIssue == nil {
comment.DependentIssue = issues[comment.DependentIssueID]
if comment.DependentIssue != nil {
if err := comment.DependentIssue.LoadRepo(ctx); err != nil {
return err
}
}
}
}
return nil
}
// getAttachmentCommentIDs only return the comment ids which possibly has attachments
func (comments CommentList) getAttachmentCommentIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.ID, comment.Type.HasAttachmentSupport()
})
}
// LoadAttachmentsByIssue loads attachments by issue id
func (comments CommentList) LoadAttachmentsByIssue(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
attachments := make([]*repo_model.Attachment, 0, len(comments)/2)
if err := db.GetEngine(ctx).Where("issue_id=? AND comment_id>0", comments[0].IssueID).Find(&attachments); err != nil {
return err
}
commentAttachmentsMap := make(map[int64][]*repo_model.Attachment, len(comments))
for _, attach := range attachments {
commentAttachmentsMap[attach.CommentID] = append(commentAttachmentsMap[attach.CommentID], attach)
}
for _, comment := range comments {
comment.Attachments = commentAttachmentsMap[comment.ID]
}
return nil
}
// LoadAttachments loads attachments
func (comments CommentList) LoadAttachments(ctx context.Context) (err error) {
if len(comments) == 0 {
return nil
}
attachments := make(map[int64][]*repo_model.Attachment, len(comments))
commentsIDs := comments.getAttachmentCommentIDs()
left := len(commentsIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("comment_id", commentsIDs[:limit]).
Rows(new(repo_model.Attachment))
if err != nil {
return err
}
for rows.Next() {
var attachment repo_model.Attachment
err = rows.Scan(&attachment)
if err != nil {
_ = rows.Close()
return err
}
attachments[attachment.CommentID] = append(attachments[attachment.CommentID], &attachment)
}
_ = rows.Close()
left -= limit
commentsIDs = commentsIDs[limit:]
}
for _, comment := range comments {
comment.Attachments = attachments[comment.ID]
}
return nil
}
func (comments CommentList) getReviewIDs() []int64 {
return container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.ReviewID, comment.ReviewID > 0
})
}
func (comments CommentList) loadReviews(ctx context.Context) error {
if len(comments) == 0 {
return nil
}
reviewIDs := comments.getReviewIDs()
if len(reviewIDs) == 0 {
return nil
}
reviews := make(map[int64]*Review, len(reviewIDs))
if err := db.GetEngine(ctx).In("id", reviewIDs).Find(&reviews); err != nil {
return err
}
for _, comment := range comments {
comment.Review = reviews[comment.ReviewID]
if comment.Review == nil {
// review request which has been replaced by actual reviews doesn't exist in database anymore, so don't log errors for them.
if comment.ReviewID > 0 && comment.Type != CommentTypeReviewRequest {
log.Error("comment with review id [%d] but has no review record", comment.ReviewID)
}
continue
}
// If the comment dismisses a review, we need to load the reviewer to show whose review has been dismissed.
// Otherwise, the reviewer is the poster of the comment, so we don't need to load it.
if comment.Type == CommentTypeDismissReview {
if err := comment.Review.LoadReviewer(ctx); err != nil {
return err
}
}
}
return nil
}
// LoadAttributes loads attributes of the comments, except for attachments and
// comments
func (comments CommentList) LoadAttributes(ctx context.Context) (err error) {
if err = comments.LoadPosters(ctx); err != nil {
return err
}
if err = comments.loadLabels(ctx); err != nil {
return err
}
if err = comments.loadMilestones(ctx); err != nil {
return err
}
if err = comments.loadOldMilestones(ctx); err != nil {
return err
}
if err = comments.loadAssignees(ctx); err != nil {
return err
}
if err = comments.LoadAttachments(ctx); err != nil {
return err
}
if err = comments.loadReviews(ctx); err != nil {
return err
}
if err = comments.LoadIssues(ctx); err != nil {
return err
}
return comments.loadDependentIssues(ctx)
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"time"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestCreateComment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
now := time.Now().Unix()
comment, err := issues_model.CreateComment(t.Context(), &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeComment,
Doer: doer,
Repo: repo,
Issue: issue,
Content: "Hello",
})
assert.NoError(t, err)
then := time.Now().Unix()
assert.Equal(t, issues_model.CommentTypeComment, comment.Type)
assert.Equal(t, "Hello", comment.Content)
assert.Equal(t, issue.ID, comment.IssueID)
assert.Equal(t, doer.ID, comment.PosterID)
unittest.AssertInt64InRange(t, now, then, int64(comment.CreatedUnix))
unittest.AssertExistsAndLoadBean(t, comment) // assert actually added to DB
updatedIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
}
func Test_UpdateCommentAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
attachment := repo_model.Attachment{
Name: "test.txt",
}
assert.NoError(t, db.Insert(t.Context(), &attachment))
err := issues_model.UpdateCommentAttachments(t.Context(), comment, []string{attachment.UUID})
assert.NoError(t, err)
attachment2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: attachment.ID})
assert.Equal(t, attachment.Name, attachment2.Name)
assert.Equal(t, comment.ID, attachment2.CommentID)
assert.Equal(t, comment.IssueID, attachment2.IssueID)
}
func TestFetchCodeComments(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
res, err := issues_model.FetchCodeComments(t.Context(), issue, user, false)
assert.NoError(t, err)
assert.Contains(t, res, "README.md")
assert.Contains(t, res["README.md"], int64(4))
assert.Len(t, res["README.md"][4], 1)
assert.Equal(t, int64(4), res["README.md"][4][0].ID)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
res, err = issues_model.FetchCodeComments(t.Context(), issue, user2, false)
assert.NoError(t, err)
assert.Len(t, res, 1)
}
func TestAsCommentType(t *testing.T) {
assert.Equal(t, issues_model.CommentTypeComment, issues_model.CommentType(0))
assert.Equal(t, issues_model.CommentTypeUndefined, issues_model.AsCommentType(""))
assert.Equal(t, issues_model.CommentTypeUndefined, issues_model.AsCommentType("nonsense"))
assert.Equal(t, issues_model.CommentTypeComment, issues_model.AsCommentType("comment"))
assert.Equal(t, issues_model.CommentTypePRUnScheduledToAutoMerge, issues_model.AsCommentType("pull_cancel_scheduled_merge"))
}
func TestMigrate_InsertIssueComments(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
_ = issue.LoadRepo(t.Context())
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
reaction := &issues_model.Reaction{
Type: "heart",
UserID: owner.ID,
}
comment := &issues_model.Comment{
PosterID: owner.ID,
Poster: owner,
IssueID: issue.ID,
Issue: issue,
Reactions: []*issues_model.Reaction{reaction},
}
err := issues_model.InsertIssueComments(t.Context(), []*issues_model.Comment{comment})
assert.NoError(t, err)
issueModified := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.Equal(t, issue.NumComments+1, issueModified.NumComments)
unittest.CheckConsistencyFor(t, &issues_model.Issue{})
}
func Test_UpdateIssueNumComments(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
assert.NoError(t, issues_model.UpdateIssueNumComments(t.Context(), issue2.ID))
issue2 = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
assert.Equal(t, 1, issue2.NumComments)
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/avatars"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ContentHistory save issue/comment content history revisions.
type ContentHistory struct {
ID int64 `xorm:"pk autoincr"`
PosterID int64
IssueID int64 `xorm:"INDEX"`
CommentID int64 `xorm:"INDEX"`
EditedUnix timeutil.TimeStamp `xorm:"INDEX"`
ContentText string `xorm:"LONGTEXT"`
IsFirstCreated bool
IsDeleted bool
}
// TableName provides the real table name
func (m *ContentHistory) TableName() string {
return "issue_content_history"
}
func init() {
db.RegisterModel(new(ContentHistory))
}
// SaveIssueContentHistory save history
func SaveIssueContentHistory(ctx context.Context, posterID, issueID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error {
ch := &ContentHistory{
PosterID: posterID,
IssueID: issueID,
CommentID: commentID,
ContentText: contentText,
EditedUnix: editTime,
IsFirstCreated: isFirstCreated,
}
if err := db.Insert(ctx, ch); err != nil {
log.Error("can not save issue content history. err=%v", err)
return err
}
// We only keep at most 20 history revisions now. It is enough in most cases.
// If there is a special requirement to keep more, we can consider introducing a new setting option then, but not now.
KeepLimitedContentHistory(ctx, issueID, commentID, 20)
return nil
}
// KeepLimitedContentHistory keeps at most `limit` history revisions, it will hard delete out-dated revisions, sorting by revision interval
// we can ignore all errors in this function, so we just log them
func KeepLimitedContentHistory(ctx context.Context, issueID, commentID int64, limit int) {
type IDEditTime struct {
ID int64
EditedUnix timeutil.TimeStamp
}
var res []*IDEditTime
err := db.GetEngine(ctx).Select("id, edited_unix").Table("issue_content_history").
Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
OrderBy("edited_unix ASC").
Find(&res)
if err != nil {
log.Error("can not query content history for deletion, err=%v", err)
return
}
if len(res) <= 2 {
return
}
outDatedCount := len(res) - limit
for outDatedCount > 0 {
var indexToDelete int
minEditedInterval := -1
// find a history revision with minimal edited interval to delete, the first and the last should never be deleted
for i := 1; i < len(res)-1; i++ {
editedInterval := int(res[i].EditedUnix - res[i-1].EditedUnix)
if minEditedInterval == -1 || editedInterval < minEditedInterval {
minEditedInterval = editedInterval
indexToDelete = i
}
}
if indexToDelete == 0 {
break
}
// hard delete the found one
_, err = db.GetEngine(ctx).Delete(&ContentHistory{ID: res[indexToDelete].ID})
if err != nil {
log.Error("can not delete out-dated content history, err=%v", err)
break
}
res = append(res[:indexToDelete], res[indexToDelete+1:]...)
outDatedCount--
}
}
// QueryIssueContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main issue)
// only return the count map for "edited" (history revision count > 1) issues or comments.
func QueryIssueContentHistoryEditedCountMap(dbCtx context.Context, issueID int64) (map[int64]int, error) {
type HistoryCountRecord struct {
CommentID int64
HistoryCount int
}
records := make([]*HistoryCountRecord, 0)
err := db.GetEngine(dbCtx).Select("comment_id, COUNT(1) as history_count").
Table("issue_content_history").
Where(builder.Eq{"issue_id": issueID}).
GroupBy("comment_id").
Having("count(1) > 1").
Find(&records)
if err != nil {
log.Error("can not query issue content history count map. err=%v", err)
return nil, err
}
res := map[int64]int{}
for _, r := range records {
res[r.CommentID] = r.HistoryCount
}
return res, nil
}
// IssueContentListItem the list for web ui
type IssueContentListItem struct {
UserID int64
UserName string
UserFullName string
UserAvatarLink string
HistoryID int64
EditedUnix timeutil.TimeStamp
IsFirstCreated bool
IsDeleted bool
}
// FetchIssueContentHistoryList fetch list
func FetchIssueContentHistoryList(dbCtx context.Context, issueID, commentID int64) ([]*IssueContentListItem, error) {
res := make([]*IssueContentListItem, 0)
err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name, u.full_name as user_full_name,"+
"h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted").
Table([]string{"issue_content_history", "h"}).
Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id").
Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
OrderBy("edited_unix DESC").
Find(&res)
if err != nil {
log.Error("can not fetch issue content history list. err=%v", err)
return nil, err
}
for _, item := range res {
if item.UserID > 0 {
item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
} else {
item.UserAvatarLink = avatars.DefaultAvatarLink()
}
}
return res, nil
}
// HasIssueContentHistory check if a ContentHistory entry exists
func HasIssueContentHistory(dbCtx context.Context, issueID, commentID int64) (bool, error) {
exists, err := db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).Exist(&ContentHistory{})
if err != nil {
return false, fmt.Errorf("can not check issue content history. err: %w", err)
}
return exists, err
}
// SoftDeleteIssueContentHistory soft delete
func SoftDeleteIssueContentHistory(dbCtx context.Context, historyID int64) error {
if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ContentHistory{
IsDeleted: true,
ContentText: "",
}); err != nil {
log.Error("failed to soft delete issue content history. err=%v", err)
return err
}
return nil
}
// ErrIssueContentHistoryNotExist not exist error
type ErrIssueContentHistoryNotExist struct {
ID int64
}
// Error error string
func (err ErrIssueContentHistoryNotExist) Error() string {
return fmt.Sprintf("issue content history does not exist [id: %d]", err.ID)
}
func (err ErrIssueContentHistoryNotExist) Unwrap() error {
return util.ErrNotExist
}
// GetIssueContentHistoryByID get issue content history
func GetIssueContentHistoryByID(dbCtx context.Context, id int64) (*ContentHistory, error) {
h := &ContentHistory{}
has, err := db.GetEngine(dbCtx).ID(id).Get(h)
if err != nil {
return nil, err
} else if !has {
return nil, ErrIssueContentHistoryNotExist{id}
}
return h, nil
}
// GetIssueContentHistoryAndPrev get a history and the previous non-deleted history (to compare)
func GetIssueContentHistoryAndPrev(dbCtx context.Context, issueID, id int64) (history, prevHistory *ContentHistory, err error) {
history = &ContentHistory{}
has, err := db.GetEngine(dbCtx).Where("id=? AND issue_id=?", id, issueID).Get(history)
if err != nil {
log.Error("failed to get issue content history %v. err=%v", id, err)
return nil, nil, err
} else if !has {
log.Error("issue content history does not exist. id=%v. err=%v", id, err)
return nil, nil, &ErrIssueContentHistoryNotExist{id}
}
prevHistory = &ContentHistory{}
has, err = db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": history.IssueID, "comment_id": history.CommentID, "is_deleted": false}).
And(builder.Lt{"edited_unix": history.EditedUnix}).
OrderBy("edited_unix DESC").Limit(1).
Get(prevHistory)
if err != nil {
log.Error("failed to get issue content history %v. err=%v", id, err)
return nil, nil, err
} else if !has {
return history, nil, nil
}
return history, prevHistory, nil
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestContentHistory(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
dbCtx := t.Context()
timeStampNow := timeutil.TimeStampNow()
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow, "i-a", true)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow.Add(2), "i-b", false)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow.Add(7), "i-c", false)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow, "c-a", true)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(5), "c-b", false)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(20), "c-c", false)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(50), "c-d", false)
_ = issues_model.SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(51), "c-e", false)
h1, _ := issues_model.GetIssueContentHistoryByID(dbCtx, 1)
assert.EqualValues(t, 1, h1.ID)
m, _ := issues_model.QueryIssueContentHistoryEditedCountMap(dbCtx, 10)
assert.Equal(t, 3, m[0])
assert.Equal(t, 5, m[100])
/*
we can not have this test with real `User` now, because we can not depend on `User` model (circle-import), so there is no `user` table
when the refactor of models are done, this test will be possible to be run then with a real `User` model.
*/
type User struct {
ID int64
Name string
FullName string
}
_ = db.GetEngine(dbCtx).Sync(&User{})
list1, _ := issues_model.FetchIssueContentHistoryList(dbCtx, 10, 0)
assert.Len(t, list1, 3)
list2, _ := issues_model.FetchIssueContentHistoryList(dbCtx, 10, 100)
assert.Len(t, list2, 5)
hasHistory1, _ := issues_model.HasIssueContentHistory(dbCtx, 10, 0)
assert.True(t, hasHistory1)
hasHistory2, _ := issues_model.HasIssueContentHistory(dbCtx, 10, 1)
assert.False(t, hasHistory2)
h6, h6Prev, _ := issues_model.GetIssueContentHistoryAndPrev(dbCtx, 10, 6)
assert.EqualValues(t, 6, h6.ID)
assert.EqualValues(t, 5, h6Prev.ID)
// soft-delete
_ = issues_model.SoftDeleteIssueContentHistory(dbCtx, 5)
h6, h6Prev, _ = issues_model.GetIssueContentHistoryAndPrev(dbCtx, 10, 6)
assert.EqualValues(t, 6, h6.ID)
assert.EqualValues(t, 4, h6Prev.ID)
// only keep 3 history revisions for comment_id=100, the first and the last should never be deleted
issues_model.KeepLimitedContentHistory(dbCtx, 10, 100, 3)
list1, _ = issues_model.FetchIssueContentHistoryList(dbCtx, 10, 0)
assert.Len(t, list1, 3)
list2, _ = issues_model.FetchIssueContentHistoryList(dbCtx, 10, 100)
assert.Len(t, list2, 3)
assert.EqualValues(t, 8, list2[0].HistoryID)
assert.EqualValues(t, 7, list2[1].HistoryID)
assert.EqualValues(t, 4, list2[2].HistoryID)
}
func TestHasIssueContentHistoryForCommentOnly(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_ = db.TruncateBeans(t.Context(), &issues_model.ContentHistory{})
hasHistory1, _ := issues_model.HasIssueContentHistory(t.Context(), 10, 0)
assert.False(t, hasHistory1)
hasHistory2, _ := issues_model.HasIssueContentHistory(t.Context(), 10, 100)
assert.False(t, hasHistory2)
_ = issues_model.SaveIssueContentHistory(t.Context(), 1, 10, 100, timeutil.TimeStampNow(), "c-a", true)
_ = issues_model.SaveIssueContentHistory(t.Context(), 1, 10, 100, timeutil.TimeStampNow().Add(5), "c-b", false)
hasHistory1, _ = issues_model.HasIssueContentHistory(t.Context(), 10, 0)
assert.False(t, hasHistory1)
hasHistory2, _ = issues_model.HasIssueContentHistory(t.Context(), 10, 100)
assert.True(t, hasHistory2)
}
+201
View File
@@ -0,0 +1,201 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
)
// ErrDependencyExists represents a "DependencyAlreadyExists" kind of error.
type ErrDependencyExists struct {
IssueID int64
DependencyID int64
}
// IsErrDependencyExists checks if an error is a ErrDependencyExists.
func IsErrDependencyExists(err error) bool {
_, ok := err.(ErrDependencyExists)
return ok
}
func (err ErrDependencyExists) Error() string {
return fmt.Sprintf("issue dependency does already exist [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID)
}
func (err ErrDependencyExists) Unwrap() error {
return util.ErrAlreadyExist
}
// ErrDependencyNotExists represents a "DependencyAlreadyExists" kind of error.
type ErrDependencyNotExists struct {
IssueID int64
DependencyID int64
}
// IsErrDependencyNotExists checks if an error is a ErrDependencyExists.
func IsErrDependencyNotExists(err error) bool {
_, ok := err.(ErrDependencyNotExists)
return ok
}
func (err ErrDependencyNotExists) Error() string {
return fmt.Sprintf("issue dependency does not exist [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID)
}
func (err ErrDependencyNotExists) Unwrap() error {
return util.ErrNotExist
}
// ErrCircularDependency represents a "DependencyCircular" kind of error.
type ErrCircularDependency struct {
IssueID int64
DependencyID int64
}
// IsErrCircularDependency checks if an error is a ErrCircularDependency.
func IsErrCircularDependency(err error) bool {
_, ok := err.(ErrCircularDependency)
return ok
}
func (err ErrCircularDependency) Error() string {
return fmt.Sprintf("circular dependencies exists (two issues blocking each other) [issue id: %d, dependency id: %d]", err.IssueID, err.DependencyID)
}
// ErrDependenciesLeft represents an error where the issue you're trying to close still has dependencies left.
type ErrDependenciesLeft struct {
IssueID int64
}
// IsErrDependenciesLeft checks if an error is a ErrDependenciesLeft.
func IsErrDependenciesLeft(err error) bool {
_, ok := err.(ErrDependenciesLeft)
return ok
}
func (err ErrDependenciesLeft) Error() string {
return fmt.Sprintf("issue has open dependencies [issue id: %d]", err.IssueID)
}
// ErrUnknownDependencyType represents an error where an unknown dependency type was passed
type ErrUnknownDependencyType struct {
Type DependencyType
}
func (err ErrUnknownDependencyType) Error() string {
return fmt.Sprintf("unknown dependency type [type: %d]", err.Type)
}
func (err ErrUnknownDependencyType) Unwrap() error {
return util.ErrInvalidArgument
}
// IssueDependency represents an issue dependency
type IssueDependency struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
IssueID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL"`
DependencyID int64 `xorm:"UNIQUE(issue_dependency) NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
func init() {
db.RegisterModel(new(IssueDependency))
}
// DependencyType Defines Dependency Type Constants
type DependencyType int
// Define Dependency Types
const (
DependencyTypeBlockedBy DependencyType = iota
DependencyTypeBlocking
)
// CreateIssueDependency creates a new dependency for an issue
func CreateIssueDependency(ctx context.Context, user *user_model.User, issue, dep *Issue) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// Check if it already exists
exists, err := issueDepExists(ctx, issue.ID, dep.ID)
if err != nil {
return err
}
if exists {
return ErrDependencyExists{issue.ID, dep.ID}
}
// And if it would be circular
circular, err := issueDepExists(ctx, dep.ID, issue.ID)
if err != nil {
return err
}
if circular {
return ErrCircularDependency{issue.ID, dep.ID}
}
if err := db.Insert(ctx, &IssueDependency{
UserID: user.ID,
IssueID: issue.ID,
DependencyID: dep.ID,
}); err != nil {
return err
}
// Add comment referencing the new dependency
return createIssueDependencyComment(ctx, user, issue, dep, true)
})
}
// RemoveIssueDependency removes a dependency from an issue
func RemoveIssueDependency(ctx context.Context, user *user_model.User, issue, dep *Issue, depType DependencyType) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
var issueDepToDelete IssueDependency
switch depType {
case DependencyTypeBlockedBy:
issueDepToDelete = IssueDependency{IssueID: issue.ID, DependencyID: dep.ID}
case DependencyTypeBlocking:
issueDepToDelete = IssueDependency{IssueID: dep.ID, DependencyID: issue.ID}
default:
return ErrUnknownDependencyType{depType}
}
affected, err := db.GetEngine(ctx).Delete(&issueDepToDelete)
if err != nil {
return err
}
// If we deleted nothing, the dependency did not exist
if affected <= 0 {
return ErrDependencyNotExists{issue.ID, dep.ID}
}
// Add comment referencing the removed dependency
return createIssueDependencyComment(ctx, user, issue, dep, false)
})
}
// Check if the dependency already exists
func issueDepExists(ctx context.Context, issueID, depID int64) (bool, error) {
return db.GetEngine(ctx).Where("(issue_id = ? AND dependency_id = ?)", issueID, depID).Exist(&IssueDependency{})
}
// IssueNoDependenciesLeft checks if issue can be closed
func IssueNoDependenciesLeft(ctx context.Context, issue *Issue) (bool, error) {
exists, err := db.GetEngine(ctx).
Table("issue_dependency").
Select("issue.*").
Join("INNER", "issue", "issue.id = issue_dependency.dependency_id").
Where("issue_dependency.issue_id = ?", issue.ID).
And("issue.is_closed = ?", "0").
Exist(&Issue{})
return !exists, err
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestCreateIssueDependency(t *testing.T) {
// Prepare
assert.NoError(t, unittest.PrepareTestDatabase())
user1, err := user_model.GetUserByID(t.Context(), 1)
assert.NoError(t, err)
issue1, err := issues_model.GetIssueByID(t.Context(), 1)
assert.NoError(t, err)
issue2, err := issues_model.GetIssueByID(t.Context(), 2)
assert.NoError(t, err)
// Create a dependency and check if it was successful
err = issues_model.CreateIssueDependency(t.Context(), user1, issue1, issue2)
assert.NoError(t, err)
// Do it again to see if it will check if the dependency already exists
err = issues_model.CreateIssueDependency(t.Context(), user1, issue1, issue2)
assert.Error(t, err)
assert.True(t, issues_model.IsErrDependencyExists(err))
// Check for circular dependencies
err = issues_model.CreateIssueDependency(t.Context(), user1, issue2, issue1)
assert.Error(t, err)
assert.True(t, issues_model.IsErrCircularDependency(err))
_ = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeAddDependency, PosterID: user1.ID, IssueID: issue1.ID})
// Check if dependencies left is correct
left, err := issues_model.IssueNoDependenciesLeft(t.Context(), issue1)
assert.NoError(t, err)
assert.False(t, left)
// Close #2 and check again
_, err = issues_model.CloseIssue(t.Context(), issue2, user1)
assert.NoError(t, err)
issue2Closed, err := issues_model.GetIssueByID(t.Context(), 2)
assert.NoError(t, err)
assert.True(t, issue2Closed.IsClosed)
left, err = issues_model.IssueNoDependenciesLeft(t.Context(), issue1)
assert.NoError(t, err)
assert.True(t, left)
// Test removing the dependency
err = issues_model.RemoveIssueDependency(t.Context(), user1, issue1, issue2, issues_model.DependencyTypeBlockedBy)
assert.NoError(t, err)
_, err = issues_model.ReopenIssue(t.Context(), issue2, user1)
assert.NoError(t, err)
issue2Reopened, err := issues_model.GetIssueByID(t.Context(), 2)
assert.NoError(t, err)
assert.False(t, issue2Reopened.IsClosed)
}
+813
View File
@@ -0,0 +1,813 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"html/template"
"regexp"
"slices"
"strconv"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
type ErrIssueNotExist struct {
ID int64
RepoID int64
Index int64
}
// IsErrIssueNotExist checks if an error is a ErrIssueNotExist.
func IsErrIssueNotExist(err error) bool {
_, ok := err.(ErrIssueNotExist)
return ok
}
func (err ErrIssueNotExist) Error() string {
return fmt.Sprintf("issue does not exist [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index)
}
func (err ErrIssueNotExist) Unwrap() error {
return util.ErrNotExist
}
var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already changed")
// Issue represents an issue or pull request of repository.
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
Repo *repo_model.Repository `xorm:"-"`
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"`
OriginalAuthor string
OriginalAuthorID int64 `xorm:"index"`
Title string `xorm:"name"`
Content string `xorm:"LONGTEXT"`
RenderedContent template.HTML `xorm:"-"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
Labels []*Label `xorm:"-"`
isLabelsLoaded bool `xorm:"-"`
MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"`
isMilestoneLoaded bool `xorm:"-"`
Projects []*project_model.Project `xorm:"-"`
isProjectsLoaded bool `xorm:"-"`
Priority int
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
isAssigneeLoaded bool `xorm:"-"`
IsClosed bool `xorm:"INDEX"`
IsRead bool `xorm:"-"`
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
PullRequest *PullRequest `xorm:"-"`
NumComments int
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
Ref string
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ClosedUnix timeutil.TimeStamp `xorm:"INDEX"`
Attachments []*repo_model.Attachment `xorm:"-"`
isAttachmentsLoaded bool `xorm:"-"`
Comments CommentList `xorm:"-"`
Reactions ReactionList `xorm:"-"`
TotalTrackedTime int64 `xorm:"-"`
Assignees []*user_model.User `xorm:"-"`
// IsLocked limits commenting abilities to users on an issue
// with write access
IsLocked bool `xorm:"NOT NULL DEFAULT false"`
// For view issue page.
ShowRole RoleDescriptor `xorm:"-"`
// Time estimate
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
}
var (
issueTasksPat = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`)
issueTasksDonePat = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`)
)
// IssueIndex represents the issue index table
type IssueIndex db.ResourceIndex
func init() {
db.RegisterModel(new(Issue))
db.RegisterModel(new(IssueIndex))
}
// LoadTotalTimes load total tracked time
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
opts := FindTrackedTimesOptions{IssueID: issue.ID}
issue.TotalTrackedTime, err = opts.toSession(db.GetEngine(ctx)).SumInt(&TrackedTime{}, "time")
if err != nil {
return err
}
return nil
}
// IsOverdue checks if the issue is overdue
func (issue *Issue) IsOverdue() bool {
if issue.IsClosed {
return issue.ClosedUnix >= issue.DeadlineUnix
}
return timeutil.TimeStampNow() >= issue.DeadlineUnix
}
// LoadRepo loads issue's repository
func (issue *Issue) LoadRepo(ctx context.Context) (err error) {
if issue.Repo == nil && issue.RepoID != 0 {
issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID)
if err != nil {
return fmt.Errorf("getRepositoryByID [%d]: %w", issue.RepoID, err)
}
}
return nil
}
func (issue *Issue) LoadAttachments(ctx context.Context) (err error) {
if issue.isAttachmentsLoaded || issue.Attachments != nil {
return nil
}
issue.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, issue.ID)
if err != nil {
return fmt.Errorf("getAttachmentsByIssueID [%d]: %w", issue.ID, err)
}
issue.isAttachmentsLoaded = true
return nil
}
// IsTimetrackerEnabled returns true if the repo enables timetracking
func (issue *Issue) IsTimetrackerEnabled(ctx context.Context) bool {
if err := issue.LoadRepo(ctx); err != nil {
log.Error(fmt.Sprintf("loadRepo: %v", err))
return false
}
return issue.Repo.IsTimetrackerEnabled(ctx)
}
// LoadPoster loads poster
func (issue *Issue) LoadPoster(ctx context.Context) (err error) {
if issue.Poster != nil {
return nil
}
issue.PosterID, issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID)
return err
}
// LoadPullRequest loads pull request info
func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) {
if issue.IsPull {
if issue.PullRequest == nil && issue.ID != 0 {
issue.PullRequest, err = GetPullRequestByIssueID(ctx, issue.ID)
if err != nil {
if IsErrPullRequestNotExist(err) {
return err
}
return fmt.Errorf("getPullRequestByIssueID [%d]: %w", issue.ID, err)
}
}
if issue.PullRequest != nil {
issue.PullRequest.Issue = issue
}
}
return nil
}
func (issue *Issue) loadComments(ctx context.Context) (err error) {
return issue.loadCommentsByType(ctx, CommentTypeUndefined)
}
// LoadDiscussComments loads discuss comments
func (issue *Issue) LoadDiscussComments(ctx context.Context) error {
return issue.loadCommentsByType(ctx, CommentTypeComment)
}
func (issue *Issue) loadCommentsByType(ctx context.Context, tp CommentType) (err error) {
if issue.Comments != nil {
return nil
}
issue.Comments, err = FindComments(ctx, &FindCommentsOptions{
IssueID: issue.ID,
Type: tp,
})
for _, comment := range issue.Comments {
comment.Issue = issue
}
return err
}
func (issue *Issue) loadReactions(ctx context.Context) (err error) {
if issue.Reactions != nil {
return nil
}
reactions, _, err := FindReactions(ctx, FindReactionsOptions{
IssueID: issue.ID,
})
if err != nil {
return err
}
if err = issue.LoadRepo(ctx); err != nil {
return err
}
// Load reaction user data
if _, err := reactions.LoadUsers(ctx, issue.Repo); err != nil {
return err
}
// Cache comments to map
comments := make(map[int64]*Comment)
for _, comment := range issue.Comments {
comments[comment.ID] = comment
}
// Add reactions either to issue or comment
for _, react := range reactions {
if react.CommentID == 0 {
issue.Reactions = append(issue.Reactions, react)
} else if comment, ok := comments[react.CommentID]; ok {
comment.Reactions = append(comment.Reactions, react)
}
}
return nil
}
// LoadMilestone load milestone of this issue.
func (issue *Issue) LoadMilestone(ctx context.Context) (err error) {
if !issue.isMilestoneLoaded && (issue.Milestone == nil || issue.Milestone.ID != issue.MilestoneID) && issue.MilestoneID > 0 {
issue.Milestone, err = GetMilestoneByRepoID(ctx, issue.RepoID, issue.MilestoneID)
if err != nil && !IsErrMilestoneNotExist(err) {
return fmt.Errorf("getMilestoneByRepoID [repo_id: %d, milestone_id: %d]: %w", issue.RepoID, issue.MilestoneID, err)
}
issue.isMilestoneLoaded = true
}
return nil
}
func (issue *Issue) LoadPinOrder(ctx context.Context) error {
if issue.PinOrder != 0 {
return nil
}
issuePin, err := GetIssuePin(ctx, issue)
if err != nil && !db.IsErrNotExist(err) {
return err
}
if issuePin != nil {
issue.PinOrder = issuePin.PinOrder
} else {
issue.PinOrder = -1
}
return nil
}
// LoadAttributes loads the attribute of this issue.
func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
if err = issue.LoadRepo(ctx); err != nil {
return err
}
if err = issue.LoadPoster(ctx); err != nil {
return err
}
if err = issue.LoadLabels(ctx); err != nil {
return err
}
if err = issue.LoadMilestone(ctx); err != nil {
return err
}
if err = issue.LoadProjects(ctx); err != nil {
return err
}
if err = issue.LoadAssignees(ctx); err != nil {
return err
}
if err = issue.LoadPullRequest(ctx); err != nil && !IsErrPullRequestNotExist(err) {
// It is possible pull request is not yet created.
return err
}
if err = issue.LoadAttachments(ctx); err != nil {
return err
}
if err = issue.loadComments(ctx); err != nil {
return err
}
if err = issue.LoadPinOrder(ctx); err != nil {
return err
}
if err = issue.Comments.LoadAttributes(ctx); err != nil {
return err
}
if issue.IsTimetrackerEnabled(ctx) {
if err = issue.LoadTotalTimes(ctx); err != nil {
return err
}
}
return issue.loadReactions(ctx)
}
// IsPinned returns if a Issue is pinned
func (issue *Issue) IsPinned() bool {
if issue.PinOrder == 0 {
setting.PanicInDevOrTesting("issue's pinorder has not been loaded")
}
return issue.PinOrder > 0
}
func (issue *Issue) ResetAttributesLoaded() {
issue.isLabelsLoaded = false
issue.isMilestoneLoaded = false
issue.isAttachmentsLoaded = false
issue.isAssigneeLoaded = false
issue.isProjectsLoaded = false
}
// GetIsRead load the `IsRead` field of the issue
func (issue *Issue) GetIsRead(ctx context.Context, userID int64) error {
issueUser := &IssueUser{IssueID: issue.ID, UID: userID}
if has, err := db.GetEngine(ctx).Get(issueUser); err != nil {
return err
} else if !has {
issue.IsRead = false
return nil
}
issue.IsRead = issueUser.IsRead
return nil
}
// APIURL returns the absolute APIURL to this issue.
func (issue *Issue) APIURL(ctx context.Context) string {
if issue.Repo == nil {
err := issue.LoadRepo(ctx)
if err != nil {
log.Error("Issue[%d].APIURL(): %v", issue.ID, err)
return ""
}
}
return fmt.Sprintf("%s/issues/%d", issue.Repo.APIURL(), issue.Index)
}
// HTMLURL returns the absolute URL to this issue.
func (issue *Issue) HTMLURL(ctx context.Context) string {
var path string
if issue.IsPull {
path = "pulls"
} else {
path = "issues"
}
return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(ctx), path, issue.Index)
}
// Link returns the issue's relative URL.
func (issue *Issue) Link() string {
var path string
if issue.IsPull {
path = "pulls"
} else {
path = "issues"
}
return fmt.Sprintf("%s/%s/%d", issue.Repo.Link(), path, issue.Index)
}
// DiffURL returns the absolute URL to this diff
func (issue *Issue) DiffURL() string {
if issue.IsPull {
return fmt.Sprintf("%s/pulls/%d.diff", issue.Repo.HTMLURL(), issue.Index)
}
return ""
}
// PatchURL returns the absolute URL to this patch
func (issue *Issue) PatchURL() string {
if issue.IsPull {
return fmt.Sprintf("%s/pulls/%d.patch", issue.Repo.HTMLURL(), issue.Index)
}
return ""
}
// State returns string representation of issue status.
func (issue *Issue) State() api.StateType {
if issue.IsClosed {
return api.StateClosed
}
return api.StateOpen
}
// HashTag returns unique hash tag for issue.
func (issue *Issue) HashTag() string {
return fmt.Sprintf("issue-%d", issue.ID)
}
// IsPoster returns true if given user by ID is the poster.
func (issue *Issue) IsPoster(uid int64) bool {
return issue.OriginalAuthorID == 0 && issue.PosterID == uid
}
// GetTasks returns the amount of tasks in the issues content
func (issue *Issue) GetTasks() int {
return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
}
// GetTasksDone returns the amount of completed tasks in the issues content
func (issue *Issue) GetTasksDone() int {
return len(issueTasksDonePat.FindAllStringIndex(issue.Content, -1))
}
// GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close.
func (issue *Issue) GetLastEventTimestamp() timeutil.TimeStamp {
if issue.IsClosed {
return issue.ClosedUnix
}
return issue.CreatedUnix
}
// GetLastEventLabel returns the localization label for the current issue.
func (issue *Issue) GetLastEventLabel() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {
return "repo.pulls.merged_by"
}
return "repo.issues.closed_by"
}
return "repo.issues.opened_by"
}
// GetLastComment return last comment for the current issue.
func (issue *Issue) GetLastComment(ctx context.Context) (*Comment, error) {
var c Comment
exist, err := db.GetEngine(ctx).Where("type = ?", CommentTypeComment).
And("issue_id = ?", issue.ID).Desc("created_unix").Get(&c)
if err != nil {
return nil, err
}
if !exist {
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
}
return &c, nil
}
// GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username.
func (issue *Issue) GetLastEventLabelFake() string {
if issue.IsClosed {
if issue.IsPull && issue.PullRequest.HasMerged {
return "repo.pulls.merged_by_fake"
}
return "repo.issues.closed_by_fake"
}
return "repo.issues.opened_by_fake"
}
// GetIssueByIndex returns raw issue without loading attributes by index in a repository.
func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
if index < 1 {
return nil, ErrIssueNotExist{}
}
issue := &Issue{
RepoID: repoID,
Index: index,
}
has, err := db.GetEngine(ctx).Get(issue)
if err != nil {
return nil, err
} else if !has {
return nil, ErrIssueNotExist{0, repoID, index}
}
return issue, nil
}
func isPullToCond(isPull optional.Option[bool]) builder.Cond {
if isPull.Has() {
return builder.Eq{"is_pull": isPull.Value()}
}
return builder.NewCond()
}
func FindLatestUpdatedIssues(ctx context.Context, repoID int64, isPull optional.Option[bool], pageSize int) (IssueList, error) {
issues := make([]*Issue, 0, pageSize)
err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
And(isPullToCond(isPull)).
OrderBy("updated_unix DESC").
Limit(pageSize).
Find(&issues)
return issues, err
}
func FindIssuesSuggestionByKeyword(ctx context.Context, repoID int64, keyword string, isPull optional.Option[bool], excludedID int64, pageSize int) (IssueList, error) {
cond := builder.NewCond()
if excludedID > 0 {
cond = cond.And(builder.Neq{"`id`": excludedID})
}
// It seems that GitHub searches both title and content (maybe sorting by the search engine's ranking system?)
// The first PR (https://github.com/go-gitea/gitea/pull/32327) uses "search indexer" to search "name(title) + content"
// But it seems that searching "content" (especially LIKE by DB engine) generates worse (unusable) results.
// So now (https://github.com/go-gitea/gitea/pull/33538) it only searches "name(title)", leave the improvements to the future.
cond = cond.And(db.BuildCaseInsensitiveLike("`name`", keyword))
issues := make([]*Issue, 0, pageSize)
err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
And(isPullToCond(isPull)).
And(cond).
OrderBy("updated_unix DESC, `index` DESC").
Limit(pageSize).
Find(&issues)
return issues, err
}
// GetIssueWithAttrsByIndex returns issue by index in a repository.
func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
issue, err := GetIssueByIndex(ctx, repoID, index)
if err != nil {
return nil, err
}
return issue, issue.LoadAttributes(ctx)
}
// GetIssueByID returns an issue by given ID.
func GetIssueByID(ctx context.Context, id int64) (*Issue, error) {
issue := new(Issue)
has, err := db.GetEngine(ctx).ID(id).Get(issue)
if err != nil {
return nil, err
} else if !has {
return nil, ErrIssueNotExist{id, 0, 0}
}
return issue, nil
}
func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) {
issue := new(Issue)
has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue)
if err != nil {
return nil, err
} else if !has {
return nil, ErrIssueNotExist{issueID, repoID, 0}
}
return issue, nil
}
// GetIssuesByIDs return issues with the given IDs.
// If keepOrder is true, the order of the returned issues will be the same as the given IDs.
func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) {
issues := make([]*Issue, 0, len(issueIDs))
if len(issueIDs) == 0 {
return issues, nil
}
if err := db.GetEngine(ctx).In("id", issueIDs).Find(&issues); err != nil {
return nil, err
}
if len(keepOrder) > 0 && keepOrder[0] {
m := make(map[int64]*Issue, len(issues))
appended := container.Set[int64]{}
for _, issue := range issues {
m[issue.ID] = issue
}
issues = issues[:0]
for _, id := range issueIDs {
if issue, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended
appended.Add(id)
issues = append(issues, issue)
}
}
}
return issues, nil
}
// GetIssueIDsByRepoID returns all issue ids by repo id
func GetIssueIDsByRepoID(ctx context.Context, repoID int64) ([]int64, error) {
ids := make([]int64, 0, 10)
err := db.GetEngine(ctx).Table("issue").Cols("id").Where("repo_id = ?", repoID).Find(&ids)
return ids, err
}
// GetParticipantsIDsByIssueID returns the IDs of all users who participated in comments of an issue,
// but skips joining with `user` for performance reasons.
// User permissions must be verified elsewhere if required.
func GetParticipantsIDsByIssueID(ctx context.Context, issueID int64) ([]int64, error) {
userIDs := make([]int64, 0, 5)
return userIDs, db.GetEngine(ctx).
Table("comment").
Cols("poster_id").
Where("issue_id = ?", issueID).
And("type in (?,?,?)", CommentTypeComment, CommentTypeCode, CommentTypeReview).
Distinct("poster_id").
Find(&userIDs)
}
// IsUserParticipantsOfIssue return true if user is participants of an issue
func IsUserParticipantsOfIssue(ctx context.Context, user *user_model.User, issue *Issue) bool {
userIDs, err := issue.GetParticipantIDsByIssue(ctx)
if err != nil {
log.Error(err.Error())
return false
}
return slices.Contains(userIDs, user.ID)
}
// DependencyInfo represents high level information about an issue which is a dependency of another issue.
type DependencyInfo struct {
Issue `xorm:"extends"`
repo_model.Repository `xorm:"extends"`
}
// GetParticipantIDsByIssue returns all userIDs who are participated in comments of an issue and issue author
func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, error) {
if issue == nil {
return nil, nil
}
userIDs := make([]int64, 0, 5)
if err := db.GetEngine(ctx).Table("comment").Cols("poster_id").
Where("`comment`.issue_id = ?", issue.ID).
And("`comment`.type in (?,?,?)", CommentTypeComment, CommentTypeCode, CommentTypeReview).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
Join("INNER", "`user`", "`user`.id = `comment`.poster_id").
Distinct("poster_id").
Find(&userIDs); err != nil {
return nil, fmt.Errorf("get poster IDs: %w", err)
}
if !slices.Contains(userIDs, issue.PosterID) {
return append(userIDs, issue.PosterID), nil
}
return userIDs, nil
}
// BlockedByDependencies finds all Dependencies an issue is blocked by
func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, total int64, err error) {
sess := db.GetEngine(ctx).
Table("issue").
Join("INNER", "repository", "repository.id = issue.repo_id").
Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id").
Where("issue_id = ?", issue.ID).
// sort by repo id then created date, with the issues of the same repo at the beginning of the list
OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID)
if opts.Page > 0 {
db.SetSessionPagination(sess, &opts)
}
total, err = sess.FindAndCount(&issueDeps)
for _, depInfo := range issueDeps {
depInfo.Issue.Repo = &depInfo.Repository
}
return issueDeps, total, err
}
// BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks
func (issue *Issue) BlockingDependencies(ctx context.Context) (issueDeps []*DependencyInfo, err error) {
err = db.GetEngine(ctx).
Table("issue").
Join("INNER", "repository", "repository.id = issue.repo_id").
Join("INNER", "issue_dependency", "issue_dependency.issue_id = issue.id").
Where("dependency_id = ?", issue.ID).
// sort by repo id then created date, with the issues of the same repo at the beginning of the list
OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID).
Find(&issueDeps)
for _, depInfo := range issueDeps {
depInfo.Issue.Repo = &depInfo.Repository
}
return issueDeps, err
}
func migratedIssueCond(tp api.GitServiceType) builder.Cond {
return builder.In("issue_id",
builder.Select("issue.id").
From("issue").
InnerJoin("repository", "issue.repo_id = repository.id").
Where(builder.Eq{
"repository.original_service_type": tp,
}),
)
}
// RemapExternalUser ExternalUserRemappable interface
func (issue *Issue) RemapExternalUser(externalName string, externalID, userID int64) error {
issue.OriginalAuthor = externalName
issue.OriginalAuthorID = externalID
issue.PosterID = userID
return nil
}
// GetUserID ExternalUserRemappable interface
func (issue *Issue) GetUserID() int64 { return issue.PosterID }
// GetExternalName ExternalUserRemappable interface
func (issue *Issue) GetExternalName() string { return issue.OriginalAuthor }
// GetExternalID ExternalUserRemappable interface
func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
// HasOriginalAuthor returns if an issue was migrated and has an original author.
func (issue *Issue) HasOriginalAuthor() bool {
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
}
// InsertIssues insert issues to database
func InsertIssues(ctx context.Context, issues ...*Issue) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for _, issue := range issues {
if err := insertIssue(ctx, issue); err != nil {
return err
}
}
return nil
})
}
func insertIssue(ctx context.Context, issue *Issue) error {
sess := db.GetEngine(ctx)
if _, err := sess.NoAutoTime().Insert(issue); err != nil {
return err
}
issueLabels := make([]IssueLabel, 0, len(issue.Labels))
for _, label := range issue.Labels {
issueLabels = append(issueLabels, IssueLabel{
IssueID: issue.ID,
LabelID: label.ID,
})
}
if len(issueLabels) > 0 {
if _, err := sess.Insert(issueLabels); err != nil {
return err
}
}
for _, reaction := range issue.Reactions {
reaction.IssueID = issue.ID
}
if len(issue.Reactions) > 0 {
if _, err := sess.Insert(issue.Reactions); err != nil {
return err
}
}
return nil
}
// ChangeIssueTimeEstimate changes the plan time of this issue, as the given user.
func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model.User, timeEstimate int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := UpdateIssueCols(ctx, &Issue{ID: issue.ID, TimeEstimate: timeEstimate}, "time_estimate"); err != nil {
return fmt.Errorf("updateIssueCols: %w", err)
}
if err := issue.LoadRepo(ctx); err != nil {
return fmt.Errorf("loadRepo: %w", err)
}
opts := &CreateCommentOptions{
Type: CommentTypeChangeTimeEstimate,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Content: strconv.FormatInt(timeEstimate, 10),
}
if _, err := CreateComment(ctx, opts); err != nil {
return fmt.Errorf("createComment: %w", err)
}
return nil
})
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"gitea.dev/models/db"
)
// RecalculateIssueIndexForRepo create issue_index for repo if not exist and
// update it based on highest index of existing issues assigned to a repo
func RecalculateIssueIndexForRepo(ctx context.Context, repoID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
var maxIndex int64
if _, err := db.GetEngine(ctx).Select(" MAX(`index`)").Table("issue").Where("repo_id=?", repoID).Get(&maxIndex); err != nil {
return err
}
return db.SyncMaxResourceIndex(ctx, "issue_index", repoID, maxIndex)
})
}
+470
View File
@@ -0,0 +1,470 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"sort"
"gitea.dev/models/db"
access_model "gitea.dev/models/perm/access"
user_model "gitea.dev/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 err
}
opts := &CreateCommentOptions{
Type: CommentTypeLabel,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Label: label,
Content: "1",
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
issue.Labels = append(issue.Labels, label)
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(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
if HasIssueLabel(ctx, issue.ID, label.ID) {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
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.isLabelsLoaded = false
issue.Labels = nil
return issue.LoadLabels(ctx)
})
}
// 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
}
if err = issue.LoadLabels(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 = RemoveDuplicateExclusiveIssueLabels(ctx, issue, l, doer); err != nil {
return err
}
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(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
if err = newIssueLabels(ctx, issue, labels, doer); err != nil {
return err
}
// reload all labels
issue.isLabelsLoaded = false
issue.Labels = nil
return issue.LoadLabels(ctx)
})
}
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 err
}
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
issue.isLabelsLoaded = false
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.isLabelsLoaded && 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)
}
issue.isLabelsLoaded = true
}
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(ctx context.Context, issue *Issue, doer *user_model.User) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := issue.LoadRepo(ctx); err != nil {
return err
} else if err = issue.LoadPullRequest(ctx); err != nil {
return err
}
perm, err := access_model.GetDoerRepoPermission(ctx, issue.Repo, doer)
if err != nil {
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
return ErrRepoLabelNotExist{}
}
return clearIssueLabels(ctx, issue, doer)
})
}
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(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
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
return issue.LoadLabels(ctx)
})
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestNewIssueLabelsScope(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18})
label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
label2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, issues_model.NewIssueLabels(t.Context(), issue, []*issues_model.Label{label1, label2}, doer))
assert.Len(t, issue.Labels, 1)
assert.Equal(t, label2.ID, issue.Labels[0].ID)
}
+610
View File
@@ -0,0 +1,610 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"xorm.io/builder"
)
// IssueList defines a list of issues
type IssueList []*Issue
// get the repo IDs to be loaded later, these IDs are for issue.Repo and issue.PullRequest.HeadRepo
func (issues IssueList) getRepoIDs() []int64 {
return container.FilterSlice(issues, func(issue *Issue) (int64, bool) {
if issue.Repo == nil {
return issue.RepoID, true
}
if issue.PullRequest != nil && issue.PullRequest.HeadRepo == nil {
return issue.PullRequest.HeadRepoID, true
}
return 0, false
})
}
// LoadRepositories loads issues' all repositories
func (issues IssueList) LoadRepositories(ctx context.Context) (repo_model.RepositoryList, error) {
if len(issues) == 0 {
return nil, nil
}
repoIDs := issues.getRepoIDs()
repoMaps := make(map[int64]*repo_model.Repository, len(repoIDs))
left := len(repoIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
err := db.GetEngine(ctx).
In("id", repoIDs[:limit]).
Find(&repoMaps)
if err != nil {
return nil, fmt.Errorf("find repository: %w", err)
}
left -= limit
repoIDs = repoIDs[limit:]
}
for _, issue := range issues {
if issue.Repo == nil {
issue.Repo = repoMaps[issue.RepoID]
} else {
repoMaps[issue.RepoID] = issue.Repo
}
if issue.PullRequest != nil {
issue.PullRequest.BaseRepo = issue.Repo
if issue.PullRequest.HeadRepo == nil {
issue.PullRequest.HeadRepo = repoMaps[issue.PullRequest.HeadRepoID]
}
}
}
return repo_model.ValuesRepository(repoMaps), nil
}
func (issues IssueList) LoadPosters(ctx context.Context) error {
if len(issues) == 0 {
return nil
}
posterIDs := container.FilterSlice(issues, func(issue *Issue) (int64, bool) {
return issue.PosterID, issue.Poster == nil && issue.PosterID > 0
})
posterMaps, err := user_model.GetUsersMapByIDs(ctx, posterIDs)
if err != nil {
return err
}
for _, issue := range issues {
if issue.Poster == nil {
issue.Poster = user_model.GetPossibleUserFromMap(issue.PosterID, posterMaps)
}
}
return nil
}
func (issues IssueList) getIssueIDs() []int64 {
ids := make([]int64, 0, len(issues))
for _, issue := range issues {
ids = append(ids, issue.ID)
}
return ids
}
func (issues IssueList) LoadLabels(ctx context.Context) error {
if len(issues) == 0 {
return nil
}
type LabelIssue struct {
Label *Label `xorm:"extends"`
IssueLabel *IssueLabel `xorm:"extends"`
}
issueLabels := make(map[int64][]*Label, len(issues)*3)
issueIDs := issues.getIssueIDs()
left := len(issueIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).Table("label").
Join("LEFT", "issue_label", "issue_label.label_id = label.id").
In("issue_label.issue_id", issueIDs[:limit]).
Asc("label.name").
Rows(new(LabelIssue))
if err != nil {
return err
}
for rows.Next() {
var labelIssue LabelIssue
err = rows.Scan(&labelIssue)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.LoadLabels: Close: %w", err1)
}
return err
}
issueLabels[labelIssue.IssueLabel.IssueID] = append(issueLabels[labelIssue.IssueLabel.IssueID], labelIssue.Label)
}
// When there are no rows left and we try to close it.
// Since that is not relevant for us, we can safely ignore it.
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.LoadLabels: Close: %w", err1)
}
left -= limit
issueIDs = issueIDs[limit:]
}
for _, issue := range issues {
issue.Labels = issueLabels[issue.ID]
issue.isLabelsLoaded = true
}
return nil
}
func (issues IssueList) getMilestoneIDs() []int64 {
return container.FilterSlice(issues, func(issue *Issue) (int64, bool) {
return issue.MilestoneID, true
})
}
func (issues IssueList) LoadMilestones(ctx context.Context) error {
milestoneIDs := issues.getMilestoneIDs()
if len(milestoneIDs) == 0 {
return nil
}
milestoneMaps := make(map[int64]*Milestone, len(milestoneIDs))
left := len(milestoneIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
err := db.GetEngine(ctx).
In("id", milestoneIDs[:limit]).
Find(&milestoneMaps)
if err != nil {
return err
}
left -= limit
milestoneIDs = milestoneIDs[limit:]
}
for _, issue := range issues {
issue.Milestone = milestoneMaps[issue.MilestoneID]
issue.isMilestoneLoaded = true
}
return nil
}
func (issues IssueList) LoadProjects(ctx context.Context) error {
issueIDs := issues.getIssueIDs()
issueProjectMaps := make(map[int64][]*project_model.Project, len(issues))
left := len(issueIDs)
type projectWithIssueID struct {
*project_model.Project `xorm:"extends"`
IssueID int64
}
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
projects := make([]*projectWithIssueID, 0, limit)
err := db.GetEngine(ctx).
Table("project").
Select("project.*, project_issue.issue_id").
Join("INNER", "project_issue", "project.id = project_issue.project_id").
In("project_issue.issue_id", issueIDs[:limit]).
OrderBy("project_issue.issue_id ASC, project.id ASC").
Find(&projects)
if err != nil {
return err
}
for _, project := range projects {
issueProjectMaps[project.IssueID] = append(issueProjectMaps[project.IssueID], project.Project)
}
left -= limit
issueIDs = issueIDs[limit:]
}
for _, issue := range issues {
issue.Projects = issueProjectMaps[issue.ID]
issue.isProjectsLoaded = true
}
return nil
}
func (issues IssueList) LoadAssignees(ctx context.Context) error {
if len(issues) == 0 {
return nil
}
type AssigneeIssue struct {
IssueAssignee *IssueAssignees `xorm:"extends"`
Assignee *user_model.User `xorm:"extends"`
}
assignees := make(map[int64][]*user_model.User, len(issues))
issueIDs := issues.getIssueIDs()
left := len(issueIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).Table("issue_assignees").
Join("INNER", "`user`", "`user`.id = `issue_assignees`.assignee_id").
In("`issue_assignees`.issue_id", issueIDs[:limit]).OrderBy(user_model.GetOrderByName()).
Rows(new(AssigneeIssue))
if err != nil {
return err
}
for rows.Next() {
var assigneeIssue AssigneeIssue
err = rows.Scan(&assigneeIssue)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadAssignees: Close: %w", err1)
}
return err
}
assignees[assigneeIssue.IssueAssignee.IssueID] = append(assignees[assigneeIssue.IssueAssignee.IssueID], assigneeIssue.Assignee)
}
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadAssignees: Close: %w", err1)
}
left -= limit
issueIDs = issueIDs[limit:]
}
for _, issue := range issues {
issue.Assignees = assignees[issue.ID]
if len(issue.Assignees) > 0 {
issue.Assignee = issue.Assignees[0]
}
issue.isAssigneeLoaded = true
}
return nil
}
func (issues IssueList) getPullIssueIDs() []int64 {
ids := make([]int64, 0, len(issues))
for _, issue := range issues {
if issue.IsPull && issue.PullRequest == nil {
ids = append(ids, issue.ID)
}
}
return ids
}
// LoadPullRequests loads pull requests
func (issues IssueList) LoadPullRequests(ctx context.Context) error {
issuesIDs := issues.getPullIssueIDs()
if len(issuesIDs) == 0 {
return nil
}
pullRequestMaps := make(map[int64]*PullRequest, len(issuesIDs))
left := len(issuesIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("issue_id", issuesIDs[:limit]).
Rows(new(PullRequest))
if err != nil {
return err
}
for rows.Next() {
var pr PullRequest
err = rows.Scan(&pr)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadPullRequests: Close: %w", err1)
}
return err
}
pullRequestMaps[pr.IssueID] = &pr
}
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadPullRequests: Close: %w", err1)
}
left -= limit
issuesIDs = issuesIDs[limit:]
}
for _, issue := range issues {
issue.PullRequest = pullRequestMaps[issue.ID]
if issue.PullRequest != nil {
issue.PullRequest.Issue = issue
}
}
return nil
}
// LoadAttachments loads attachments
func (issues IssueList) LoadAttachments(ctx context.Context) (err error) {
if len(issues) == 0 {
return nil
}
attachments := make(map[int64][]*repo_model.Attachment, len(issues))
issuesIDs := issues.getIssueIDs()
left := len(issuesIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).
In("issue_id", issuesIDs[:limit]).
Rows(new(repo_model.Attachment))
if err != nil {
return err
}
for rows.Next() {
var attachment repo_model.Attachment
err = rows.Scan(&attachment)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadAttachments: Close: %w", err1)
}
return err
}
attachments[attachment.IssueID] = append(attachments[attachment.IssueID], &attachment)
}
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadAttachments: Close: %w", err1)
}
left -= limit
issuesIDs = issuesIDs[limit:]
}
for _, issue := range issues {
issue.Attachments = attachments[issue.ID]
issue.isAttachmentsLoaded = true
}
return nil
}
func (issues IssueList) loadComments(ctx context.Context, cond builder.Cond) (err error) {
if len(issues) == 0 {
return nil
}
comments := make(map[int64][]*Comment, len(issues))
issuesIDs := issues.getIssueIDs()
left := len(issuesIDs)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
rows, err := db.GetEngine(ctx).Table("comment").
Join("INNER", "issue", "issue.id = comment.issue_id").
In("issue.id", issuesIDs[:limit]).
Where(cond).
NoAutoCondition().
Rows(new(Comment))
if err != nil {
return err
}
for rows.Next() {
var comment Comment
err = rows.Scan(&comment)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadComments: Close: %w", err1)
}
return err
}
comments[comment.IssueID] = append(comments[comment.IssueID], &comment)
}
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadComments: Close: %w", err1)
}
left -= limit
issuesIDs = issuesIDs[limit:]
}
for _, issue := range issues {
issue.Comments = comments[issue.ID]
}
return nil
}
func (issues IssueList) loadTotalTrackedTimes(ctx context.Context) (err error) {
type totalTimesByIssue struct {
IssueID int64
Time int64
}
if len(issues) == 0 {
return nil
}
trackedTimes := make(map[int64]int64, len(issues))
reposMap := make(map[int64]*repo_model.Repository, len(issues))
for _, issue := range issues {
reposMap[issue.RepoID] = issue.Repo
}
repos := repo_model.RepositoryListOfMap(reposMap)
if err := repos.LoadUnits(ctx); err != nil {
return err
}
ids := make([]int64, 0, len(issues))
for _, issue := range issues {
if issue.Repo.IsTimetrackerEnabled(ctx) {
ids = append(ids, issue.ID)
}
}
left := len(ids)
for left > 0 {
limit := min(left, db.DefaultMaxInSize)
// select issue_id, sum(time) from tracked_time where issue_id in (<issue ids in current page>) group by issue_id
rows, err := db.GetEngine(ctx).Table("tracked_time").
Where("deleted = ?", false).
Select("issue_id, sum(time) as time").
In("issue_id", ids[:limit]).
GroupBy("issue_id").
Rows(new(totalTimesByIssue))
if err != nil {
return err
}
for rows.Next() {
var totalTime totalTimesByIssue
err = rows.Scan(&totalTime)
if err != nil {
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadTotalTrackedTimes: Close: %w", err1)
}
return err
}
trackedTimes[totalTime.IssueID] = totalTime.Time
}
if err1 := rows.Close(); err1 != nil {
return fmt.Errorf("IssueList.loadTotalTrackedTimes: Close: %w", err1)
}
left -= limit
ids = ids[limit:]
}
for _, issue := range issues {
issue.TotalTrackedTime = trackedTimes[issue.ID]
}
return nil
}
func (issues IssueList) LoadPinOrder(ctx context.Context) error {
if len(issues) == 0 {
return nil
}
issueIDs := container.FilterSlice(issues, func(issue *Issue) (int64, bool) {
return issue.ID, issue.PinOrder == 0
})
if len(issueIDs) == 0 {
return nil
}
issuePins, err := GetIssuePinsByIssueIDs(ctx, issueIDs)
if err != nil {
return err
}
for _, issue := range issues {
if issue.PinOrder != 0 {
continue
}
for _, pin := range issuePins {
if pin.IssueID == issue.ID {
issue.PinOrder = pin.PinOrder
break
}
}
if issue.PinOrder == 0 {
issue.PinOrder = -1
}
}
return nil
}
// loadAttributes loads all attributes, expect for attachments and comments
func (issues IssueList) LoadAttributes(ctx context.Context) error {
if _, err := issues.LoadRepositories(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: LoadRepositories: %w", err)
}
if err := issues.LoadPosters(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: LoadPosters: %w", err)
}
if err := issues.LoadLabels(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: LoadLabels: %w", err)
}
if err := issues.LoadMilestones(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: LoadMilestones: %w", err)
}
if err := issues.LoadProjects(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: loadProjects: %w", err)
}
if err := issues.LoadAssignees(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: loadAssignees: %w", err)
}
if err := issues.LoadPullRequests(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: loadPullRequests: %w", err)
}
if err := issues.loadTotalTrackedTimes(ctx); err != nil {
return fmt.Errorf("issue.loadAttributes: loadTotalTrackedTimes: %w", err)
}
return nil
}
// LoadComments loads comments
func (issues IssueList) LoadComments(ctx context.Context) error {
return issues.loadComments(ctx, builder.NewCond())
}
// LoadDiscussComments loads discuss comments
func (issues IssueList) LoadDiscussComments(ctx context.Context) error {
return issues.loadComments(ctx, builder.Eq{"comment.type": CommentTypeComment})
}
// GetApprovalCounts returns a map of issue ID to slice of approval counts
// FIXME: only returns official counts due to double counting of non-official approvals
func (issues IssueList) GetApprovalCounts(ctx context.Context) (map[int64][]*ReviewCount, error) {
rCounts := make([]*ReviewCount, 0, 2*len(issues))
ids := make([]int64, len(issues))
for i, issue := range issues {
ids[i] = issue.ID
}
sess := db.GetEngine(ctx).In("issue_id", ids)
err := sess.Select("issue_id, type, count(id) as `count`").
Where("official = ? AND dismissed = ?", true, false).
GroupBy("issue_id, type").
OrderBy("issue_id").
Table("review").
Find(&rCounts)
if err != nil {
return nil, err
}
approvalCountMap := make(map[int64][]*ReviewCount, len(issues))
for _, c := range rCounts {
approvalCountMap[c.IssueID] = append(approvalCountMap[c.IssueID], c)
}
return approvalCountMap, nil
}
func (issues IssueList) LoadIsRead(ctx context.Context, userID int64) error {
issueIDs := issues.getIssueIDs()
issueUsers := make([]*IssueUser, 0, len(issueIDs))
if err := db.GetEngine(ctx).Where("uid =?", userID).
In("issue_id").
Find(&issueUsers); err != nil {
return err
}
for _, issueUser := range issueUsers {
for _, issue := range issues {
if issue.ID == issueUser.IssueID {
issue.IsRead = issueUser.IsRead
}
}
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestIssueList_LoadRepositories(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issueList := issues_model.IssueList{
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}),
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}),
}
repos, err := issueList.LoadRepositories(t.Context())
assert.NoError(t, err)
assert.Len(t, repos, 2)
for _, issue := range issueList {
assert.Equal(t, issue.RepoID, issue.Repo.ID)
}
}
func TestIssueList_LoadAttributes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
setting.Service.EnableTimetracking = true
issueList := issues_model.IssueList{
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}),
}
assert.NoError(t, issueList.LoadAttributes(t.Context()))
for _, issue := range issueList {
assert.Equal(t, issue.RepoID, issue.Repo.ID)
for _, label := range issue.Labels {
assert.Equal(t, issue.RepoID, label.RepoID)
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
}
if issue.PosterID > 0 {
assert.Equal(t, issue.PosterID, issue.Poster.ID)
}
if issue.AssigneeID > 0 {
assert.Equal(t, issue.AssigneeID, issue.Assignee.ID)
}
if issue.MilestoneID > 0 {
assert.Equal(t, issue.MilestoneID, issue.Milestone.ID)
}
if issue.IsPull {
assert.Equal(t, issue.ID, issue.PullRequest.IssueID)
}
for _, attachment := range issue.Attachments {
assert.Equal(t, issue.ID, attachment.IssueID)
}
for _, comment := range issue.Comments {
assert.Equal(t, issue.ID, comment.IssueID)
}
if issue.ID == int64(1) {
assert.Equal(t, int64(400), issue.TotalTrackedTime)
assert.NotEmpty(t, issue.Projects)
assert.Equal(t, int64(1), issue.Projects[0].ID)
} else {
assert.Empty(t, issue.Projects)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
)
// IssueLockOptions defines options for locking and/or unlocking an issue/PR
type IssueLockOptions struct {
Doer *user_model.User
Issue *Issue
// Reason is the doer-provided comment message for the locked issue
// GitHub doesn't support changing the "reasons" by config file, so GitHub has pre-defined "reason" enum values.
// Gitea is not like GitHub, it allows site admin to define customized "reasons" in the config file.
// So the API caller might not know what kind of "reasons" are valid, and the customized reasons are not translatable.
// To make things clear and simple: doer have the chance to use any reason they like, we do not do validation.
Reason string
}
// LockIssue locks an issue. This would limit commenting abilities to
// users with write access to the repo
func LockIssue(ctx context.Context, opts *IssueLockOptions) error {
return updateIssueLock(ctx, opts, true)
}
// UnlockIssue unlocks a previously locked issue.
func UnlockIssue(ctx context.Context, opts *IssueLockOptions) error {
return updateIssueLock(ctx, opts, false)
}
func updateIssueLock(ctx context.Context, opts *IssueLockOptions, lock bool) error {
if opts.Issue.IsLocked == lock {
return nil
}
opts.Issue.IsLocked = lock
var commentType CommentType
if opts.Issue.IsLocked {
commentType = CommentTypeLock
} else {
commentType = CommentTypeUnlock
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err := UpdateIssueCols(ctx, opts.Issue, "is_locked"); err != nil {
return err
}
opt := &CreateCommentOptions{
Doer: opts.Doer,
Issue: opts.Issue,
Repo: opts.Issue.Repo,
Type: commentType,
Content: opts.Reason,
}
_, err := CreateComment(ctx, opt)
return err
})
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"errors"
"sort"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type IssuePin struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
IssueID int64 `xorm:"UNIQUE(s) NOT NULL"`
IsPull bool `xorm:"NOT NULL"`
PinOrder int `xorm:"DEFAULT 0"`
}
var ErrIssueMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned issues has been readched")
// IsErrIssueMaxPinReached returns if the error is, that the User can't pin more Issues
func IsErrIssueMaxPinReached(err error) bool {
return err == ErrIssueMaxPinReached
}
func init() {
db.RegisterModel(new(IssuePin))
}
func GetIssuePin(ctx context.Context, issue *Issue) (*IssuePin, error) {
pin := new(IssuePin)
has, err := db.GetEngine(ctx).
Where("repo_id = ?", issue.RepoID).
And("issue_id = ?", issue.ID).Get(pin)
if err != nil {
return nil, err
} else if !has {
return nil, db.ErrNotExist{
Resource: "IssuePin",
ID: issue.ID,
}
}
return pin, nil
}
func GetIssuePinsByIssueIDs(ctx context.Context, issueIDs []int64) ([]IssuePin, error) {
var pins []IssuePin
if err := db.GetEngine(ctx).In("issue_id", issueIDs).Find(&pins); err != nil {
return nil, err
}
return pins, nil
}
// Pin pins a Issue
func PinIssue(ctx context.Context, issue *Issue, user *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
pinnedIssuesNum, err := getPinnedIssuesNum(ctx, issue.RepoID, issue.IsPull)
if err != nil {
return err
}
// Check if the maximum allowed Pins reached
if pinnedIssuesNum >= setting.Repository.Issue.MaxPinned {
return ErrIssueMaxPinReached
}
pinnedIssuesMaxPinOrder, err := getPinnedIssuesMaxPinOrder(ctx, issue.RepoID, issue.IsPull)
if err != nil {
return err
}
if _, err = db.GetEngine(ctx).Insert(&IssuePin{
RepoID: issue.RepoID,
IssueID: issue.ID,
IsPull: issue.IsPull,
PinOrder: pinnedIssuesMaxPinOrder + 1,
}); err != nil {
return err
}
// Add the pin event to the history
_, err = CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypePin,
Doer: user,
Repo: issue.Repo,
Issue: issue,
})
return err
})
}
// UnpinIssue unpins a Issue
func UnpinIssue(ctx context.Context, issue *Issue, user *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
// This sets the Pin for all Issues that come after the unpined Issue to the correct value
cnt, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Delete(new(IssuePin))
if err != nil {
return err
}
if cnt == 0 {
return nil
}
// Add the unpin event to the history
_, err = CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeUnpin,
Doer: user,
Repo: issue.Repo,
Issue: issue,
})
return err
})
}
func getPinnedIssuesNum(ctx context.Context, repoID int64, isPull bool) (int, error) {
var pinnedIssuesNum int
_, err := db.GetEngine(ctx).SQL("SELECT count(pin_order) FROM issue_pin WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&pinnedIssuesNum)
return pinnedIssuesNum, err
}
func getPinnedIssuesMaxPinOrder(ctx context.Context, repoID int64, isPull bool) (int, error) {
var maxPinnedIssuesMaxPinOrder int
_, err := db.GetEngine(ctx).SQL("SELECT max(pin_order) FROM issue_pin WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&maxPinnedIssuesMaxPinOrder)
return maxPinnedIssuesMaxPinOrder, err
}
// MovePin moves a Pinned Issue to a new Position
func MovePin(ctx context.Context, issue *Issue, newPosition int) error {
if newPosition < 1 {
return errors.New("The Position can't be lower than 1")
}
issuePin, err := GetIssuePin(ctx, issue)
if err != nil {
return err
}
if issuePin.PinOrder == newPosition {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
if issuePin.PinOrder > newPosition { // move the issue to a lower position
_, err = db.GetEngine(ctx).Exec("UPDATE issue_pin SET pin_order = pin_order + 1 WHERE repo_id = ? AND is_pull = ? AND pin_order >= ? AND pin_order < ?", issue.RepoID, issue.IsPull, newPosition, issuePin.PinOrder)
} else { // move the issue to a higher position
// Lower the Position of all Pinned Issue that came after the current Position
_, err = db.GetEngine(ctx).Exec("UPDATE issue_pin SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ? AND pin_order <= ?", issue.RepoID, issue.IsPull, issuePin.PinOrder, newPosition)
}
if err != nil {
return err
}
_, err = db.GetEngine(ctx).
Table("issue_pin").
Where("id = ?", issuePin.ID).
Update(map[string]any{
"pin_order": newPosition,
})
return err
})
}
func GetIssuePinsByRepoID(ctx context.Context, repoID int64, isPull bool) ([]*IssuePin, error) {
var pins []*IssuePin
if err := db.GetEngine(ctx).Where("repo_id = ? AND is_pull = ?", repoID, isPull).Find(&pins); err != nil {
return nil, err
}
return pins, nil
}
// GetPinnedIssues returns the pinned Issues for the given Repo and type
func GetPinnedIssues(ctx context.Context, repoID int64, isPull bool) (IssueList, error) {
issuePins, err := GetIssuePinsByRepoID(ctx, repoID, isPull)
if err != nil {
return nil, err
}
if len(issuePins) == 0 {
return IssueList{}, nil
}
ids := make([]int64, 0, len(issuePins))
for _, pin := range issuePins {
ids = append(ids, pin.IssueID)
}
issues := make(IssueList, 0, len(ids))
if err := db.GetEngine(ctx).In("id", ids).Find(&issues); err != nil {
return nil, err
}
for _, issue := range issues {
for _, pin := range issuePins {
if pin.IssueID == issue.ID {
issue.PinOrder = pin.PinOrder
break
}
}
if (!setting.IsProd || setting.IsInTesting) && issue.PinOrder == 0 {
panic("It should not happen that a pinned Issue has no PinOrder")
}
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].PinOrder < issues[j].PinOrder
})
if err = issues.LoadAttributes(ctx); err != nil {
return nil, err
}
return issues, nil
}
// IsNewPinAllowed returns if a new Issue or Pull request can be pinned
func IsNewPinAllowed(ctx context.Context, repoID int64, isPull bool) (bool, error) {
var maxPin int
_, err := db.GetEngine(ctx).SQL("SELECT COUNT(pin_order) FROM issue_pin WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&maxPin)
if err != nil {
return false, err
}
return maxPin < setting.Repository.Issue.MaxPinned, nil
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
)
// LoadProjects loads all projects the issue is assigned to
func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
if !issue.isProjectsLoaded {
err = db.GetEngine(ctx).Table("project").
Join("INNER", "project_issue", "project.id=project_issue.project_id").
Where("project_issue.issue_id = ?", issue.ID).
OrderBy("project.id ASC").
Find(&issue.Projects)
if err == nil {
issue.isProjectsLoaded = true
}
}
return err
}
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
return projectIDs, err
}
// ProjectColumnMap returns a map of project ID to column ID for this issue.
func (issue *Issue) ProjectColumnMap(ctx context.Context) (map[int64]int64, error) {
var projIssues []project_model.ProjectIssue
if err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Find(&projIssues); err != nil {
return nil, err
}
result := make(map[int64]int64, len(projIssues))
for _, projIssue := range projIssues {
result[projIssue.ProjectID] = projIssue.ProjectColumnID
}
return result, nil
}
func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) {
issues := make([]project_model.ProjectIssue, 0)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&issues); err != nil {
return nil, err
}
result := make(map[int64]int64, len(issues))
for _, issue := range issues {
if issue.ProjectColumnID == 0 {
issue.ProjectColumnID = defaultColumnID
}
result[issue.IssueID] = issue.ProjectColumnID
}
return result, nil
}
// IssueAssignOrRemoveProject updates the projects associated with an issue.
// It adds projects that are in newProjectIDs but not currently assigned,
// and removes projects that are currently assigned but not in newProjectIDs.
// If newProjectIDs is empty, all projects are removed from the issue.
// When adding an issue to a project, it is placed in the project's default column.
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := issue.LoadRepo(ctx); err != nil {
return err
}
oldProjectIDs, err := issue.projectIDs(ctx)
if err != nil {
return err
}
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)
issue.isProjectsLoaded = false
issue.Projects = nil
if len(projectsToRemove) > 0 {
if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil {
return err
}
for _, projectID := range projectsToRemove {
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldProjectID: projectID,
ProjectID: 0,
}); err != nil {
return err
}
}
}
if len(projectsToAdd) > 0 {
projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd)
if err != nil {
return err
}
for _, projectID := range projectsToAdd {
newProject, ok := projectMap[projectID]
if !ok {
return util.NewNotExistErrorf("project %d not found", projectID)
}
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
}
defaultColumn, err := newProject.MustDefaultColumn(ctx)
if err != nil {
return err
}
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, defaultColumn.ID)
if err != nil {
return err
}
err = db.Insert(ctx, &project_model.ProjectIssue{
IssueID: issue.ID,
ProjectID: projectID,
ProjectColumnID: defaultColumn.ID,
Sorting: newSorting,
})
if err != nil {
return err
}
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldProjectID: 0,
ProjectID: projectID,
}); err != nil {
return err
}
}
}
return nil
})
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"fmt"
"testing"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueMultipleProjects(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("GeneralTest", func(t *testing.T) {
// Get test data
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
project1 := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
// Create a second project for the same repository
project2 := &project_model.Project{
Title: "Test Project 2",
RepoID: issue1.RepoID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeBasicKanban,
}
require.NoError(t, project_model.NewProject(t.Context(), project2))
defer func() {
_ = project_model.DeleteProjectByID(t.Context(), project2.ID)
}()
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Empty(t, issue1.Projects)
// assign issue to both projects (each project uses its own default column)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID})
require.NoError(t, err)
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Len(t, issue1.Projects, 1)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID})
require.NoError(t, err)
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Len(t, issue1.Projects, 2)
assert.ElementsMatch(t, []int64{project1.ID, project2.ID}, []int64{issue1.Projects[0].ID, issue1.Projects[1].ID}, "Issue should be in both projects")
// test issue's project column map
projectColumnMap, err := issue1.ProjectColumnMap(t.Context())
p1Col, _ := project1.MustDefaultColumn(t.Context())
p2Col, _ := project2.MustDefaultColumn(t.Context())
require.NoError(t, err)
assert.Equal(t, p1Col.ID, projectColumnMap[project1.ID])
assert.Equal(t, p2Col.ID, projectColumnMap[project2.ID])
// only keep project2
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Len(t, issue1.Projects, 1)
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
// also test ResetAttributesLoaded
issue1.Projects = nil
issue1.ResetAttributesLoaded()
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Len(t, issue1.Projects, 1)
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
// remove issue's projects
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Empty(t, issue1.Projects)
})
t.Run("QueryByMultipleProjectIDs", func(t *testing.T) {
// Get test data
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Create three projects
var projects []*project_model.Project
for i := 1; i <= 3; i++ {
project := &project_model.Project{
Title: fmt.Sprintf("Query Test Project %d", i),
RepoID: issue1.RepoID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeBasicKanban,
}
require.NoError(t, project_model.NewProject(t.Context(), project))
projects = append(projects, project)
defer func(id int64) {
_ = project_model.DeleteProjectByID(t.Context(), id)
}(project.ID)
}
// Assign issue1 to projects 1 and 2
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID})
require.NoError(t, err)
// Assign issue2 to project 3
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID})
require.NoError(t, err)
// Query for issues in project 3 only (should find issue2)
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
RepoIDs: []int64{issue1.RepoID},
ProjectIDs: []int64{projects[2].ID},
})
require.NoError(t, err)
assert.NotEmpty(t, issues, "Should find issues in project 3")
// Verify issue2 is in the results
foundIssue2 := false
for _, issue := range issues {
if issue.ID == issue2.ID {
foundIssue2 = true
break
}
}
assert.True(t, foundIssue2, "Issue 2 should be found when querying project 3")
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. It should use "AND" but not "OR".
// Clean up
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{})
require.NoError(t, err)
})
}
+517
View File
@@ -0,0 +1,517 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"strconv"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
"xorm.io/builder"
)
const ScopeSortPrefix = "scope-"
// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint:revive // export stutter
Paginator *db.ListOptions
RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories
RepoCond builder.Cond
AssigneeID string // "(none)" or "(any)" or a user ID
PosterID string // "(none)" or "(any)" or a user ID
MentionedID int64
ReviewRequestedID int64
ReviewedID int64
SubscriberID int64
MilestoneIDs []int64
ProjectIDs []int64
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
LabelIDs []int64
IncludedLabelNames []string
ExcludedLabelNames []string
IncludeMilestones []string
SortType string
IssueIDs []int64
UpdatedAfterUnix int64
UpdatedBeforeUnix int64
// prioritize issues from this repo
PriorityRepoID int64
IsArchived optional.Option[bool]
Owner *user_model.User // issues permission scope, it could be an organization or a user
Team *organization.Team // issues permission scope
Doer *user_model.User // issues permission scope
}
// Copy returns a copy of the options.
// Be careful, it's not a deep copy, so `IssuesOptions.RepoIDs = {...}` is OK while `IssuesOptions.RepoIDs[0] = ...` is not.
func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOptions {
if o == nil {
return nil
}
v := *o
for _, e := range edit {
e(&v)
}
return &v
}
// applySorts sort an issues-related session based on the provided
// sortType string
func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
// Since this sortType is dynamically created, it has to be treated specially.
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
scope := after
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
return
}
switch sortType {
case "oldest":
sess.Asc("issue.created_unix").Asc("issue.id")
case "recentupdate":
sess.Desc("issue.updated_unix").Desc("issue.created_unix").Desc("issue.id")
case "recentclose":
sess.Desc("issue.closed_unix").Desc("issue.created_unix").Desc("issue.id")
case "leastupdate":
sess.Asc("issue.updated_unix").Asc("issue.created_unix").Asc("issue.id")
case "mostcomment":
sess.Desc("issue.num_comments").Desc("issue.created_unix").Desc("issue.id")
case "leastcomment":
sess.Asc("issue.num_comments").Desc("issue.created_unix").Desc("issue.id")
case "priority":
sess.Desc("issue.priority").Desc("issue.created_unix").Desc("issue.id")
case "nearduedate":
// 253370764800 is 01/01/9999 @ 12:00am (UTC)
sess.Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
OrderBy("CASE " +
"WHEN issue.deadline_unix = 0 AND (milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL) THEN 253370764800 " +
"WHEN milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL THEN issue.deadline_unix " +
"WHEN milestone.deadline_unix < issue.deadline_unix OR issue.deadline_unix = 0 THEN milestone.deadline_unix " +
"ELSE issue.deadline_unix END ASC").
Asc("issue.created_unix").
Asc("issue.id")
case "farduedate":
sess.Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
OrderBy("CASE " +
"WHEN milestone.deadline_unix IS NULL THEN issue.deadline_unix " +
"WHEN milestone.deadline_unix < issue.deadline_unix OR issue.deadline_unix = 0 THEN milestone.deadline_unix " +
"ELSE issue.deadline_unix END DESC").
Desc("issue.created_unix").
Desc("issue.id")
case "priorityrepo":
sess.OrderBy("CASE "+
"WHEN issue.repo_id = ? THEN 1 "+
"ELSE 2 END ASC", priorityRepoID).
Desc("issue.created_unix").
Desc("issue.id")
case "project-column-sorting":
sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
default:
sess.Desc("issue.created_unix").Desc("issue.id")
}
}
func applyLimit(sess db.Session, opts *IssuesOptions) {
if opts.Paginator == nil || opts.Paginator.IsListAll() {
return
}
start := 0
if opts.Paginator.Page > 1 {
start = (opts.Paginator.Page - 1) * opts.Paginator.PageSize
}
sess.Limit(opts.Paginator.PageSize, start)
}
func applyLabelsCondition(sess db.Session, opts *IssuesOptions) {
if len(opts.LabelIDs) > 0 {
if opts.LabelIDs[0] == 0 {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)")
} else {
// deduplicate the label IDs for inclusion and exclusion
includedLabelIDs := make(container.Set[int64])
excludedLabelIDs := make(container.Set[int64])
for _, labelID := range opts.LabelIDs {
if labelID > 0 {
includedLabelIDs.Add(labelID)
} else if labelID < 0 { // 0 is not supported here, so just ignore it
excludedLabelIDs.Add(-labelID)
}
}
// ... and use them in a subquery of the form :
// where (select count(*) from issue_label where issue_id=issue.id and label_id in (2, 4, 6)) = 3
// This equality is guaranteed thanks to unique index (issue_id,label_id) on table issue_label.
if len(includedLabelIDs) > 0 {
subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
And(builder.In("label_id", includedLabelIDs.Values()))
sess.Where(builder.Eq{strconv.Itoa(len(includedLabelIDs)): subQuery})
}
// or (select count(*)...) = 0 for excluded labels
if len(excludedLabelIDs) > 0 {
subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
And(builder.In("label_id", excludedLabelIDs.Values()))
sess.Where(builder.Eq{"0": subQuery})
}
}
}
if len(opts.IncludedLabelNames) > 0 {
sess.In("issue.id", BuildLabelNamesIssueIDsCondition(opts.IncludedLabelNames))
}
if len(opts.ExcludedLabelNames) > 0 {
sess.And(builder.NotIn("issue.id", BuildLabelNamesIssueIDsCondition(opts.ExcludedLabelNames)))
}
}
func applyMilestoneCondition(sess db.Session, opts *IssuesOptions) {
if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID {
sess.And("issue.milestone_id = 0")
} else if len(opts.MilestoneIDs) > 0 {
sess.In("issue.milestone_id", opts.MilestoneIDs)
}
if len(opts.IncludeMilestones) > 0 {
sess.In("issue.milestone_id",
builder.Select("id").
From("milestone").
Where(builder.In("name", opts.IncludeMilestones)))
}
}
func applyProjectCondition(sess db.Session, opts *IssuesOptions) {
projectIDs := util.SliceRemoveAll(opts.ProjectIDs, 0)
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
} else if len(projectIDs) > 1 { // multiple projects
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
}
// empty projectIDs means all projects,
// do not need to apply any condition
}
func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
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.AllPublic {
if opts.RepoCond == nil {
opts.RepoCond = builder.NewCond()
}
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false})))
}
if opts.RepoCond != nil {
sess.And(opts.RepoCond)
}
}
func applyConditions(sess db.Session, opts *IssuesOptions) {
if len(opts.IssueIDs) > 0 {
sess.In("issue.id", opts.IssueIDs)
}
applyRepoConditions(sess, opts)
if opts.IsClosed.Has() {
sess.And("issue.is_closed=?", opts.IsClosed.Value())
}
applyAssigneeCondition(sess, opts.AssigneeID)
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)
}
if opts.SubscriberID > 0 {
applySubscribedCondition(sess, opts.SubscriberID)
}
applyMilestoneCondition(sess, opts)
if opts.UpdatedAfterUnix != 0 {
sess.And(builder.Gte{"issue.updated_unix": opts.UpdatedAfterUnix})
}
if opts.UpdatedBeforeUnix != 0 {
sess.And(builder.Lte{"issue.updated_unix": opts.UpdatedBeforeUnix})
}
applyProjectCondition(sess, opts)
if opts.IsPull.Has() {
sess.And("issue.is_pull=?", opts.IsPull.Value())
}
if opts.IsArchived.Has() {
sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()})
}
applyLabelsCondition(sess, opts)
if opts.Owner != nil {
sess.And(repo_model.UserOwnedRepoCond(opts.Owner.ID))
}
if opts.Doer != nil && !opts.Doer.IsAdmin {
sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.Doer.ID, opts.Owner, opts.Team, opts.IsPull.Value()))
}
}
// teamUnitsRepoCond returns query condition for those repo id in the special org team with special units access
func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond {
return builder.In(id,
builder.Select("repo_id").From("team_repo").Where(
builder.Eq{
"team_id": teamID,
}.And(
builder.Or(
// Check if the user is member of the team.
builder.In(
"team_id", builder.Select("team_id").From("team_user").Where(
builder.Eq{
"uid": userID,
},
),
),
// Check if the user is in the owner team of the organisation.
builder.Exists(builder.Select("team_id").From("team_user").
Where(builder.Eq{
"org_id": orgID,
"team_id": builder.Select("id").From("team").Where(
builder.Eq{
"org_id": orgID,
"lower_name": strings.ToLower(organization.OwnerTeamName),
}),
"uid": userID,
}),
),
)).And(
builder.In(
"team_id", builder.Select("team_id").From("team_unit").Where(
builder.Eq{
"`team_unit`.org_id": orgID,
}.And(
builder.In("`team_unit`.type", units),
),
),
),
),
))
}
// issuePullAccessibleRepoCond userID must not be zero, this condition require join repository table
func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_model.User, team *organization.Team, isPull bool) builder.Cond {
cond := builder.NewCond()
unitType := unit.TypeIssues
if isPull {
unitType = unit.TypePullRequests
}
if owner != nil && owner.IsOrganization() {
if team != nil {
cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, owner.ID, team.ID, unitType)) // special team member repos
} else {
cond = cond.And(
builder.Or(
repo_model.UserOrgUnitRepoCond(repoIDstr, userID, owner.ID, unitType), // team member repos
repo_model.UserOrgPublicUnitRepoCond(userID, owner.ID), // user org public non-member repos, TODO: check repo has issues
),
)
}
} else {
cond = cond.And(
builder.Or(
repo_model.UserOwnedRepoCond(userID), // owned repos
repo_model.UserAccessRepoCond(repoIDstr, userID), // user can access repo in a unit independent way
repo_model.UserAssignedRepoCond(repoIDstr, userID), // user has been assigned accessible public repos
repo_model.UserMentionedRepoCond(repoIDstr, userID), // user has been mentioned accessible public repos
repo_model.UserCreateIssueRepoCond(repoIDstr, userID, isPull), // user has created issue/pr accessible public repos
),
)
}
return cond
}
func applyAssigneeCondition(sess db.Session, assigneeID string) {
// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
if assigneeID == "(none)" {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
} else if assigneeID == "(any)" {
sess.Where("issue.id IN (SELECT issue_id FROM issue_assignees)")
} else if assigneeIDInt64, _ := strconv.ParseInt(assigneeID, 10, 64); assigneeIDInt64 > 0 {
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
And("issue_assignees.assignee_id = ?", assigneeIDInt64)
}
}
func applyPosterCondition(sess db.Session, posterID string) {
// Actually every issue has a poster.
// The "(none)" is for internal usage only: when doer tries to search non-existing user as poster, use "(none)" to return empty result.
if posterID == "(none)" {
sess.And("issue.poster_id=0")
} else if posterIDInt64, _ := strconv.ParseInt(posterID, 10, 64); posterIDInt64 > 0 {
sess.And("issue.poster_id=?", posterIDInt64)
}
}
func applyMentionedCondition(sess db.Session, mentionedID int64) {
sess.Join("INNER", "issue_user", "issue.id = issue_user.issue_id").
And("issue_user.is_mentioned = ?", true).
And("issue_user.uid = ?", mentionedID)
}
func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
existInTeamQuery := builder.Select("team_user.team_id").
From("team_user").
Where(builder.Eq{"team_user.uid": reviewRequestedID})
// if the review is approved or rejected, it should not be shown in the review requested list
maxReview := builder.Select("MAX(r.id)").
From("review as r").
Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
subQuery := builder.Select("review.issue_id").
From("review").
Where(builder.And(
builder.Eq{"review.type": ReviewTypeRequest},
builder.Or(
builder.Eq{"review.reviewer_id": reviewRequestedID},
builder.In("review.reviewer_team_id", existInTeamQuery),
),
builder.In("review.id", maxReview),
))
sess.Where("issue.poster_id <> ?", reviewRequestedID).
And(builder.In("issue.id", subQuery))
}
func applyReviewedCondition(sess db.Session, reviewedID int64) {
// Query for pull requests where you are a reviewer or commenter, excluding
// any pull requests already returned by the review requested filter.
notPoster := builder.Neq{"issue.poster_id": reviewedID}
reviewed := builder.In("issue.id", builder.
Select("issue_id").
From("review").
Where(builder.And(
builder.Neq{"type": ReviewTypeRequest},
builder.Or(
builder.Eq{"reviewer_id": reviewedID},
builder.In("reviewer_team_id", builder.
Select("team_id").
From("team_user").
Where(builder.Eq{"uid": reviewedID}),
),
),
)),
)
commented := builder.In("issue.id", builder.
Select("issue_id").
From("comment").
Where(builder.And(
builder.Eq{"poster_id": reviewedID},
builder.In("type", CommentTypeComment, CommentTypeCode, CommentTypeReview),
)),
)
sess.And(notPoster, builder.Or(reviewed, commented))
}
func applySubscribedCondition(sess db.Session, subscriberID int64) {
sess.And(
builder.
NotIn("issue.id",
builder.Select("issue_id").
From("issue_watch").
Where(builder.Eq{"is_watching": false, "user_id": subscriberID}),
),
).And(
builder.Or(
builder.In("issue.id", builder.
Select("issue_id").
From("issue_watch").
Where(builder.Eq{"is_watching": true, "user_id": subscriberID}),
),
builder.In("issue.id", builder.
Select("issue_id").
From("comment").
Where(builder.Eq{"poster_id": subscriberID}),
),
builder.Eq{"issue.poster_id": subscriberID},
builder.In("issue.repo_id", builder.
Select("repo_id").
From("watch").
Where(builder.And(builder.Eq{"user_id": subscriberID},
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
),
),
)
}
// Issues returns a list of issues by given conditions.
func Issues(ctx context.Context, opts *IssuesOptions) (IssueList, error) {
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyLimit(sess, opts)
applyConditions(sess, opts)
applySorts(sess, opts.SortType, opts.PriorityRepoID)
issues := IssueList{}
if err := sess.Find(&issues); err != nil {
return nil, fmt.Errorf("unable to query Issues: %w", err)
}
if err := issues.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err)
}
return issues, nil
}
// IssueIDs returns a list of issue ids by given conditions.
func IssueIDs(ctx context.Context, opts *IssuesOptions, otherConds ...builder.Cond) ([]int64, int64, error) {
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyConditions(sess, opts)
for _, cond := range otherConds {
sess.And(cond)
}
applyLimit(sess, opts)
applySorts(sess, opts.SortType, opts.PriorityRepoID)
var res []int64
total, err := sess.Select("`issue`.id").Table(&Issue{}).FindAndCount(&res)
if err != nil {
return nil, 0, err
}
return res, total, nil
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
"xorm.io/builder"
)
// 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
)
// 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
var 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, otherConds ...builder.Cond) (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)
for _, cond := range otherConds {
sess.And(cond)
}
return sess.Count()
}
// GetIssueStats returns issue statistic information by given conditions.
func GetIssueStats(ctx context.Context, opts *IssuesOptions) (*IssueStats, error) {
if len(opts.IssueIDs) <= MaxQueryParameters {
return getIssueStatsChunk(ctx, 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 := min(i+MaxQueryParameters, len(opts.IssueIDs))
stats, err := getIssueStatsChunk(ctx, 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.MentionCount += stats.MentionCount
accum.ReviewRequestedCount += stats.ReviewRequestedCount
accum.ReviewedCount += stats.ReviewedCount
i = chunk
}
return accum, nil
}
func getIssueStatsChunk(ctx context.Context, opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{}
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
var err error
stats.OpenCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return stats, err
}
stats.ClosedCount, err = applyIssuesOptions(sess, opts, issueIDs).
And("issue.is_closed = ?", true).
Count(new(Issue))
return stats, err
}
func applyIssuesOptions(sess db.Session, opts *IssuesOptions, issueIDs []int64) db.Session {
if len(opts.RepoIDs) > 1 {
sess.In("issue.repo_id", opts.RepoIDs)
} else if len(opts.RepoIDs) == 1 {
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
}
if len(issueIDs) > 0 {
sess.In("issue.id", issueIDs)
}
applyLabelsCondition(sess, opts)
applyMilestoneCondition(sess, opts)
applyProjectCondition(sess, opts)
applyAssigneeCondition(sess, opts.AssigneeID)
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)
}
if opts.IsPull.Has() {
sess.And("issue.is_pull=?", opts.IsPull.Value())
}
return sess
}
// 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()
}
+467
View File
@@ -0,0 +1,467 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"fmt"
"slices"
"sort"
"sync"
"testing"
"time"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"xorm.io/builder"
)
func TestIssue_ReplaceLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) {
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
labels := make([]*issues_model.Label, len(labelIDs))
for i, labelID := range labelIDs {
labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID})
}
assert.NoError(t, issues_model.ReplaceIssueLabels(t.Context(), issue, labels, doer))
unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs))
for _, labelID := range expectedLabelIDs {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
}
}
testSuccess(1, []int64{2}, []int64{2})
testSuccess(1, []int64{1, 2}, []int64{1, 2})
testSuccess(1, []int64{}, []int64{})
// mutually exclusive scoped labels 7 and 8
testSuccess(18, []int64{6, 7}, []int64{6, 7})
testSuccess(18, []int64{7, 8}, []int64{8})
testSuccess(18, []int64{6, 8, 7}, []int64{6, 7})
}
func Test_GetIssueIDsByRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ids, err := issues_model.GetIssueIDsByRepoID(t.Context(), 1)
assert.NoError(t, err)
assert.Len(t, ids, 5)
}
func TestIssueAPIURL(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
err := issue.LoadAttributes(t.Context())
assert.NoError(t, err)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/issues/1", issue.APIURL(t.Context()))
}
func TestGetIssuesByIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(expectedIssueIDs, nonExistentIssueIDs []int64) {
issues, err := issues_model.GetIssuesByIDs(t.Context(), append(expectedIssueIDs, nonExistentIssueIDs...), true)
assert.NoError(t, err)
actualIssueIDs := make([]int64, len(issues))
for i, issue := range issues {
actualIssueIDs[i] = issue.ID
}
assert.Equal(t, expectedIssueIDs, actualIssueIDs)
}
testSuccess([]int64{1, 2, 3}, []int64{})
testSuccess([]int64{1, 2, 3}, []int64{unittest.NonexistentID})
testSuccess([]int64{3, 2, 1}, []int64{})
}
func TestGetParticipantIDsByIssue(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
checkParticipants := func(issueID int64, userIDs []int) {
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
participants, err := issue.GetParticipantIDsByIssue(t.Context())
if assert.NoError(t, err) {
participantsIDs := make([]int, len(participants))
for i, uid := range participants {
participantsIDs[i] = int(uid)
}
sort.Ints(participantsIDs)
sort.Ints(userIDs)
assert.Equal(t, userIDs, participantsIDs)
}
}
// User 1 is issue1 poster (see fixtures/issue.yml)
// User 2 only labeled issue1 (see fixtures/comment.yml)
// Users 3 and 5 made actual comments (see fixtures/comment.yml)
// User 3 is inactive, thus not active participant
checkParticipants(1, []int{1, 5})
}
func TestIssue_ClearLabels(t *testing.T) {
tests := []struct {
issueID int64
doerID int64
}{
{1, 2}, // non-pull-request, has labels
{2, 2}, // pull-request, has labels
{3, 2}, // pull-request, has no labels
}
for _, test := range tests {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: test.issueID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID})
assert.NoError(t, issues_model.ClearIssueLabels(t.Context(), issue, doer))
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: test.issueID})
}
}
func TestUpdateIssueCols(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
const newTitle = "New Title for unit test"
issue.Title = newTitle
prevContent := issue.Content
issue.Content = "This should have no effect"
now := time.Now().Unix()
assert.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "name"))
then := time.Now().Unix()
updatedIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
assert.Equal(t, newTitle, updatedIssue.Title)
assert.Equal(t, prevContent, updatedIssue.Content)
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
}
func TestIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
for _, test := range []struct {
Opts issues_model.IssuesOptions
ExpectedIssueIDs []int64
}{
{
issues_model.IssuesOptions{
AssigneeID: "1",
SortType: "oldest",
},
[]int64{1, 6},
},
{
issues_model.IssuesOptions{
RepoCond: builder.In("repo_id", 1, 3),
SortType: "oldest",
Paginator: &db.ListOptions{
Page: 1,
PageSize: 4,
},
},
[]int64{1, 2, 3, 5},
},
{
issues_model.IssuesOptions{
LabelIDs: []int64{1},
Paginator: &db.ListOptions{
Page: 1,
PageSize: 4,
},
},
[]int64{2, 1},
},
{
issues_model.IssuesOptions{
LabelIDs: []int64{1, 2},
Paginator: &db.ListOptions{
Page: 1,
PageSize: 4,
},
},
[]int64{}, // issues with **both** label 1 and 2, none of these issues matches, TODO: add more tests
},
{
issues_model.IssuesOptions{
MilestoneIDs: []int64{1},
},
[]int64{2},
},
{
issues_model.IssuesOptions{
SubscriberID: 11,
},
[]int64{11, 5, 9, 8, 3, 2, 1},
},
} {
issues, err := issues_model.Issues(t.Context(), &test.Opts)
assert.NoError(t, err)
if assert.Len(t, issues, len(test.ExpectedIssueIDs)) {
for i, issue := range issues {
assert.Equal(t, test.ExpectedIssueIDs[i], issue.ID)
}
}
}
}
func TestIssue_loadTotalTimes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ms, err := issues_model.GetIssueByID(t.Context(), 2)
assert.NoError(t, err)
assert.NoError(t, ms.LoadTotalTimes(t.Context()))
assert.Equal(t, int64(3682), ms.TotalTrackedTime)
}
func testInsertIssue(t *testing.T, title, content string, expectIndex int64) *issues_model.Issue {
var newIssue issues_model.Issue
t.Run(title, func(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := issues_model.Issue{
RepoID: repo.ID,
PosterID: user.ID,
Poster: user,
Title: title,
Content: content,
}
err := issues_model.NewIssue(t.Context(), repo, &issue, nil, nil)
assert.NoError(t, err)
has, err := db.GetEngine(t.Context()).ID(issue.ID).Get(&newIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, issue.Title, newIssue.Title)
assert.Equal(t, issue.Content, newIssue.Content)
if expectIndex > 0 {
assert.Equal(t, expectIndex, newIssue.Index)
}
})
return &newIssue
}
func TestIssue_InsertIssue(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// there are 5 issues and max index is 5 on repository 1, so this one should 6
issue := testInsertIssue(t, "my issue1", "special issue's comments?", 6)
_, err := db.DeleteByID[issues_model.Issue](t.Context(), issue.ID)
assert.NoError(t, err)
issue = testInsertIssue(t, `my issue2, this is my son's love \n \r \ `, "special issue's '' comments?", 7)
_, err = db.DeleteByID[issues_model.Issue](t.Context(), issue.ID)
assert.NoError(t, err)
}
func TestIssue_ResolveMentions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(owner, repo, doer string, mentions []string, expected []int64) {
o := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: owner})
r := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: o.ID, LowerName: repo})
issue := &issues_model.Issue{RepoID: r.ID}
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: doer})
resolved, err := issues_model.ResolveIssueMentionsByVisibility(t.Context(), issue, d, mentions)
assert.NoError(t, err)
ids := make([]int64, len(resolved))
for i, user := range resolved {
ids[i] = user.ID
}
slices.Sort(ids)
assert.Equal(t, expected, ids)
}
// Public repo, existing user
testSuccess("user2", "repo1", "user1", []string{"user5"}, []int64{5})
// Public repo, non-existing user
testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{})
// Public repo, doer
testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{})
// Private repo, team member
testSuccess("org17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
// Private repo, not a team member
testSuccess("org17", "big_test_private_4", "user20", []string{"user5"}, []int64{})
// Private repo, whole team
testSuccess("org17", "big_test_private_4", "user15", []string{"org17/owners"}, []int64{18})
}
func TestResourceIndex(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
testInsertIssue(t, fmt.Sprintf("issue %d", i+1), "my issue", 0)
})
}
wg.Wait()
}
func TestCorrectIssueStats(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Because the condition is to have chunked database look-ups,
// We have to more issues than `maxQueryParameters`, we will insert.
// maxQueryParameters + 10 issues into the testDatabase.
// Each new issues will have a constant description "Bugs are nasty"
// Which will be used later on.
defer test.MockVariableValue(&issues_model.MaxQueryParameters, 25)()
issueAmount := issues_model.MaxQueryParameters + 10
for i := range issueAmount {
testInsertIssue(t, fmt.Sprintf("Issue %d", i+1), "Bugs are nasty", 0)
}
// Now we will get all issueID's that match the "Bugs are nasty" query.
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
Paginator: &db.ListOptions{
PageSize: issueAmount,
},
RepoIDs: []int64{1},
})
total := int64(len(issues))
var ids []int64
for _, issue := range issues {
if issue.Content == "Bugs are nasty" {
ids = append(ids, issue.ID)
}
}
// Just to be sure.
assert.NoError(t, err)
assert.EqualValues(t, issueAmount, total)
// Now we will call the GetIssueStats with these IDs and if working,
// get the correct stats back.
issueStats, err := issues_model.GetIssueStats(t.Context(), &issues_model.IssuesOptions{
RepoIDs: []int64{1},
IssueIDs: ids,
})
// Now check the values.
assert.NoError(t, err)
assert.EqualValues(t, issueStats.OpenCount, issueAmount)
}
func TestMilestoneList_LoadTotalTrackedTimes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
miles := issues_model.MilestoneList{
unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}),
}
assert.NoError(t, miles.LoadTotalTrackedTimes(t.Context()))
assert.Equal(t, int64(3682), miles[0].TotalTrackedTime)
}
func TestLoadTotalTrackedTime(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
assert.NoError(t, milestone.LoadTotalTrackedTime(t.Context()))
assert.Equal(t, int64(3682), milestone.TotalTrackedTime)
}
func TestCountIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
count, err := issues_model.CountIssues(t.Context(), &issues_model.IssuesOptions{})
assert.NoError(t, err)
assert.EqualValues(t, 22, count)
}
func TestIssueLoadAttributes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
setting.Service.EnableTimetracking = true
issueList := issues_model.IssueList{
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}),
}
for _, issue := range issueList {
assert.NoError(t, issue.LoadAttributes(t.Context()))
assert.Equal(t, issue.RepoID, issue.Repo.ID)
for _, label := range issue.Labels {
assert.Equal(t, issue.RepoID, label.RepoID)
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
}
if issue.PosterID > 0 {
assert.Equal(t, issue.PosterID, issue.Poster.ID)
}
if issue.AssigneeID > 0 {
assert.Equal(t, issue.AssigneeID, issue.Assignee.ID)
}
if issue.MilestoneID > 0 {
assert.Equal(t, issue.MilestoneID, issue.Milestone.ID)
}
if issue.IsPull {
assert.Equal(t, issue.ID, issue.PullRequest.IssueID)
}
for _, attachment := range issue.Attachments {
assert.Equal(t, issue.ID, attachment.IssueID)
}
for _, comment := range issue.Comments {
assert.Equal(t, issue.ID, comment.IssueID)
}
if issue.ID == int64(1) {
assert.Equal(t, int64(400), issue.TotalTrackedTime)
assert.NotEmpty(t, issue.Projects)
assert.Equal(t, int64(1), issue.Projects[0].ID)
} else {
assert.Empty(t, issue.Projects)
}
}
}
func assertCreateIssues(t *testing.T, isPull bool) {
assert.NoError(t, unittest.PrepareTestDatabase())
reponame := "repo1"
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
assert.EqualValues(t, 1, milestone.ID)
reaction := &issues_model.Reaction{
Type: "heart",
UserID: owner.ID,
}
title := "issuetitle1"
is := &issues_model.Issue{
RepoID: repo.ID,
MilestoneID: milestone.ID,
Repo: repo,
Title: title,
Content: "issuecontent1",
IsPull: isPull,
PosterID: owner.ID,
Poster: owner,
IsClosed: true,
Labels: []*issues_model.Label{label},
Reactions: []*issues_model.Reaction{reaction},
}
err := issues_model.InsertIssues(t.Context(), is)
assert.NoError(t, err)
i := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: title})
unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: owner.ID, IssueID: i.ID})
}
func TestMigrate_CreateIssuesIsPullFalse(t *testing.T) {
assertCreateIssues(t, false)
}
func TestMigrate_CreateIssuesIsPullTrue(t *testing.T) {
assertCreateIssues(t, true)
}
+710
View File
@@ -0,0 +1,710 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"errors"
"fmt"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/organization"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/references"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// UpdateIssueCols updates cols of issue
func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error {
_, err := db.GetEngine(ctx).ID(issue.ID).Cols(cols...).Update(issue)
return err
}
// ErrIssueIsClosed is used when close a closed issue
type ErrIssueIsClosed struct {
ID int64
RepoID int64
Index int64
IsPull bool
}
// IsErrIssueIsClosed checks if an error is a ErrIssueIsClosed.
func IsErrIssueIsClosed(err error) bool {
_, ok := err.(ErrIssueIsClosed)
return ok
}
func (err ErrIssueIsClosed) Error() string {
return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already closed", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
}
func SetIssueAsClosed(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) {
if issue.IsClosed {
return nil, ErrIssueIsClosed{
ID: issue.ID,
RepoID: issue.RepoID,
Index: issue.Index,
IsPull: issue.IsPull,
}
}
// Check for open dependencies
if issue.Repo.IsDependenciesEnabled(ctx) {
// only check if dependencies are enabled and we're about to close an issue, otherwise reopening an issue would fail when there are unsatisfied dependencies
noDeps, err := IssueNoDependenciesLeft(ctx, issue)
if err != nil {
return nil, err
}
if !noDeps {
return nil, ErrDependenciesLeft{issue.ID}
}
}
issue.IsClosed = true
issue.ClosedUnix = timeutil.TimeStampNow()
if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
Where("is_closed = ?", false).
Update(issue); err != nil {
return nil, err
} else if cnt != 1 {
return nil, ErrIssueAlreadyChanged
}
return updateIssueNumbers(ctx, issue, doer, util.Iif(isMergePull, CommentTypeMergePull, CommentTypeClose))
}
// ErrIssueIsOpen is used when reopen an opened issue
type ErrIssueIsOpen struct {
ID int64
RepoID int64
IsPull bool
Index int64
}
func (err ErrIssueIsOpen) Error() string {
return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already open", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
}
func setIssueAsReopen(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
if !issue.IsClosed {
return nil, ErrIssueIsOpen{
ID: issue.ID,
RepoID: issue.RepoID,
Index: issue.Index,
IsPull: issue.IsPull,
}
}
issue.IsClosed = false
issue.ClosedUnix = 0
if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
Where("is_closed = ?", true).
Update(issue); err != nil {
return nil, err
} else if cnt != 1 {
return nil, ErrIssueAlreadyChanged
}
return updateIssueNumbers(ctx, issue, doer, CommentTypeReopen)
}
func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User, cmtType CommentType) (*Comment, error) {
// Update issue count of labels
if err := issue.LoadLabels(ctx); err != nil {
return nil, err
}
for idx := range issue.Labels {
if err := updateLabelCols(ctx, issue.Labels[idx], "num_issues", "num_closed_issue"); err != nil {
return nil, err
}
}
// Update issue count of milestone
if issue.MilestoneID > 0 {
if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil {
return nil, err
}
}
// update repository's issue closed number
switch cmtType {
case CommentTypeClose, CommentTypeMergePull:
// only increase closed count
if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil {
return nil, err
}
case CommentTypeReopen:
// only decrease closed count
if err := DecrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false, true); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("invalid comment type: %d", cmtType)
}
return CreateComment(ctx, &CreateCommentOptions{
Type: cmtType,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
})
}
// CloseIssue changes issue status to closed.
func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := issue.LoadPoster(ctx); err != nil {
return nil, err
}
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
return SetIssueAsClosed(ctx, issue, doer, false)
})
}
// ReopenIssue changes issue status to open.
func ReopenIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := issue.LoadPoster(ctx); err != nil {
return nil, err
}
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
return setIssueAsReopen(ctx, issue, doer)
})
}
// ChangeIssueTitle changes the title of this issue, as the given user.
func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User, oldTitle string) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
issue.Title = util.EllipsisDisplayString(issue.Title, 255)
if err = UpdateIssueCols(ctx, issue, "name"); err != nil {
return fmt.Errorf("updateIssueCols: %w", err)
}
if err = issue.LoadRepo(ctx); err != nil {
return fmt.Errorf("loadRepo: %w", err)
}
opts := &CreateCommentOptions{
Type: CommentTypeChangeTitle,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldTitle: oldTitle,
NewTitle: issue.Title,
}
if _, err = CreateComment(ctx, opts); err != nil {
return fmt.Errorf("createComment: %w", err)
}
return issue.AddCrossReferences(ctx, doer, true)
})
}
// ChangeIssueRef changes the branch of this issue, as the given user.
func ChangeIssueRef(ctx context.Context, issue *Issue, doer *user_model.User, oldRef string) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
if err = UpdateIssueCols(ctx, issue, "ref"); err != nil {
return fmt.Errorf("updateIssueCols: %w", err)
}
if err = issue.LoadRepo(ctx); err != nil {
return fmt.Errorf("loadRepo: %w", err)
}
oldRefFriendly := strings.TrimPrefix(oldRef, git.BranchPrefix)
newRefFriendly := strings.TrimPrefix(issue.Ref, git.BranchPrefix)
opts := &CreateCommentOptions{
Type: CommentTypeChangeIssueRef,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldRef: oldRefFriendly,
NewRef: newRefFriendly,
}
if _, err = CreateComment(ctx, opts); err != nil {
return fmt.Errorf("createComment: %w", err)
}
return nil
})
}
// AddDeletePRBranchComment adds delete branch comment for pull request issue
func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issueID int64, branchName string) error {
issue, err := GetIssueByID(ctx, issueID)
if err != nil {
return err
}
opts := &CreateCommentOptions{
Type: CommentTypeDeleteBranch,
Doer: doer,
Repo: repo,
Issue: issue,
OldRef: branchName,
}
_, err = CreateComment(ctx, opts)
return err
}
// UpdateIssueAttachments update attachments by UUIDs for the issue
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := range attachments {
attachments[i].IssueID = issueID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
}
}
return nil
})
}
// ChangeIssueContent changes issue content, as the given user.
func ChangeIssueContent(ctx context.Context, issue *Issue, doer *user_model.User, content string, contentVersion int) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
hasContentHistory, err := HasIssueContentHistory(ctx, issue.ID, 0)
if err != nil {
return fmt.Errorf("HasIssueContentHistory: %w", err)
}
if !hasContentHistory {
if err = SaveIssueContentHistory(ctx, issue.PosterID, issue.ID, 0,
issue.CreatedUnix, issue.Content, true); err != nil {
return fmt.Errorf("SaveIssueContentHistory: %w", err)
}
}
issue.Content = content
issue.ContentVersion = contentVersion + 1
affected, err := db.GetEngine(ctx).ID(issue.ID).Cols("content", "content_version").Where("content_version = ?", contentVersion).Update(issue)
if err != nil {
return err
}
if affected == 0 {
return ErrIssueAlreadyChanged
}
if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0,
timeutil.TimeStampNow(), issue.Content, false); err != nil {
return fmt.Errorf("SaveIssueContentHistory: %w", err)
}
if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
return fmt.Errorf("addCrossReferences: %w", err)
}
return nil
})
}
// NewIssueOptions represents the options of a new issue.
type NewIssueOptions struct {
Repo *repo_model.Repository
Issue *Issue
LabelIDs []int64
Attachments []string // In UUID format.
}
// NewIssueWithIndex creates issue with given index
func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssueOptions) (err error) {
e := db.GetEngine(ctx)
opts.Issue.Title = strings.TrimSpace(opts.Issue.Title)
if opts.Issue.MilestoneID > 0 {
milestone, err := GetMilestoneByRepoID(ctx, opts.Issue.RepoID, opts.Issue.MilestoneID)
if err != nil && !IsErrMilestoneNotExist(err) {
return fmt.Errorf("getMilestoneByID: %w", err)
}
// Assume milestone is invalid and drop silently.
opts.Issue.MilestoneID = 0
if milestone != nil {
opts.Issue.MilestoneID = milestone.ID
opts.Issue.Milestone = milestone
}
}
if opts.Issue.Index <= 0 {
return errors.New("no issue index provided")
}
if opts.Issue.ID > 0 {
return errors.New("issue exist")
}
if _, err := e.Insert(opts.Issue); err != nil {
return err
}
if opts.Issue.MilestoneID > 0 {
if err := UpdateMilestoneCounters(ctx, opts.Issue.MilestoneID); err != nil {
return err
}
opts := &CreateCommentOptions{
Type: CommentTypeMilestone,
Doer: doer,
Repo: opts.Repo,
Issue: opts.Issue,
OldMilestoneID: 0,
MilestoneID: opts.Issue.MilestoneID,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
}
// Update repository issue total count
if err := IncrRepoIssueNumbers(ctx, opts.Repo.ID, opts.Issue.IsPull, true); err != nil {
return err
}
if len(opts.LabelIDs) > 0 {
// During the session, SQLite3 driver cannot handle retrieve objects after update something.
// So we have to get all needed labels first.
labels := make([]*Label, 0, len(opts.LabelIDs))
if err = e.In("id", opts.LabelIDs).Find(&labels); err != nil {
return fmt.Errorf("find all labels [label_ids: %v]: %w", opts.LabelIDs, err)
}
if err = opts.Issue.LoadPoster(ctx); err != nil {
return err
}
for _, label := range labels {
// Silently drop invalid labels.
if label.RepoID != opts.Repo.ID && label.OrgID != opts.Repo.OwnerID {
continue
}
if err = newIssueLabel(ctx, opts.Issue, label, opts.Issue.Poster); err != nil {
return fmt.Errorf("addLabel [id: %d]: %w", label.ID, err)
}
}
}
if err = NewIssueUsers(ctx, opts.Repo, opts.Issue); err != nil {
return err
}
if err := UpdateIssueAttachments(ctx, opts.Issue.ID, opts.Attachments); err != nil {
return err
}
if err = opts.Issue.LoadAttributes(ctx); err != nil {
return err
}
return opts.Issue.AddCrossReferences(ctx, doer, false)
}
// NewIssue creates new issue with labels for repository.
// The title will be cut off at 255 characters if it's longer than 255 characters.
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
idx, err := db.GetNextResourceIndex(ctx, "issue_index", repo.ID)
if err != nil {
return fmt.Errorf("generate issue index failed: %w", err)
}
issue.Index = idx
issue.Title = util.EllipsisDisplayString(issue.Title, 255)
if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
Repo: repo,
Issue: issue,
LabelIDs: labelIDs,
Attachments: uuids,
}); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
return err
}
return fmt.Errorf("newIssue: %w", err)
}
return nil
})
}
// IncrRepoIssueNumbers increments repository issue numbers.
func IncrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, totalOrClosed bool) error {
dbSession := db.GetEngine(ctx)
var colName string
if totalOrClosed {
colName = util.Iif(isPull, "num_pulls", "num_issues")
} else {
colName = util.Iif(isPull, "num_closed_pulls", "num_closed_issues")
}
_, err := dbSession.Incr(colName).ID(repoID).
NoAutoCondition().NoAutoTime().
Update(new(repo_model.Repository))
return err
}
// DecrRepoIssueNumbers decrements repository issue numbers.
func DecrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, includeTotal, includeClosed bool) error {
if !includeTotal && !includeClosed {
return fmt.Errorf("no numbers to decrease for repo id %d", repoID)
}
dbSession := db.GetEngine(ctx)
if includeTotal {
colName := util.Iif(isPull, "num_pulls", "num_issues")
dbSession = dbSession.Decr(colName)
}
if includeClosed {
closedColName := util.Iif(isPull, "num_closed_pulls", "num_closed_issues")
dbSession = dbSession.Decr(closedColName)
}
_, err := dbSession.ID(repoID).
NoAutoCondition().NoAutoTime().
Update(new(repo_model.Repository))
return err
}
// UpdateIssueMentions updates issue-user relations for mentioned users.
func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_model.User) error {
if len(mentions) == 0 {
return nil
}
ids := make([]int64, len(mentions))
for i, u := range mentions {
ids[i] = u.ID
}
if err := UpdateIssueUsersByMentions(ctx, issueID, ids); err != nil {
return fmt.Errorf("UpdateIssueUsersByMentions: %w", err)
}
return nil
}
// UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it.
func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) {
// if the deadline hasn't changed do nothing
if issue.DeadlineUnix == deadlineUnix {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
// Update the deadline
if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix}, "deadline_unix"); err != nil {
return err
}
// Make the comment
if _, err = createDeadlineComment(ctx, doer, issue, deadlineUnix); err != nil {
return fmt.Errorf("createRemovedDueDateComment: %w", err)
}
return nil
})
}
// FindAndUpdateIssueMentions finds users mentioned in the given content string, and saves them in the database.
func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_model.User, content string) (mentions []*user_model.User, err error) {
rawMentions := references.FindAllMentionsMarkdown(content)
mentions, err = ResolveIssueMentionsByVisibility(ctx, issue, doer, rawMentions)
if err != nil {
return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
}
notBlocked := make([]*user_model.User, 0, len(mentions))
for _, user := range mentions {
if !user_model.IsUserBlockedBy(ctx, doer, user.ID) {
notBlocked = append(notBlocked, user)
}
}
mentions = notBlocked
if err = UpdateIssueMentions(ctx, issue.ID, mentions); err != nil {
return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
}
return mentions, err
}
// ResolveIssueMentionsByVisibility returns the users mentioned in an issue, removing those that
// don't have access to reading it. Teams are expanded into their users, but organizations are ignored.
func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *user_model.User, mentions []string) (users []*user_model.User, err error) {
if len(mentions) == 0 {
return nil, nil
}
if err = issue.LoadRepo(ctx); err != nil {
return nil, err
}
resolved := make(map[string]bool, 10)
var mentionTeams []string
if err := issue.Repo.LoadOwner(ctx); err != nil {
return nil, err
}
repoOwnerIsOrg := issue.Repo.Owner.IsOrganization()
if repoOwnerIsOrg {
mentionTeams = make([]string, 0, 5)
}
resolved[doer.LowerName] = true
for _, name := range mentions {
name := strings.ToLower(name)
if _, ok := resolved[name]; ok {
continue
}
if repoOwnerIsOrg && strings.Contains(name, "/") {
names := strings.Split(name, "/")
if len(names) < 2 || names[0] != issue.Repo.Owner.LowerName {
continue
}
mentionTeams = append(mentionTeams, names[1])
resolved[name] = true
} else {
resolved[name] = false
}
}
if issue.Repo.Owner.IsOrganization() && len(mentionTeams) > 0 {
teams := make([]*organization.Team, 0, len(mentionTeams))
if err := db.GetEngine(ctx).
Join("INNER", "team_repo", "team_repo.team_id = team.id").
Where("team_repo.repo_id=?", issue.Repo.ID).
In("team.lower_name", mentionTeams).
Find(&teams); err != nil {
return nil, fmt.Errorf("find mentioned teams: %w", err)
}
if len(teams) != 0 {
checked := make([]int64, 0, len(teams))
unittype := unit.TypeIssues
if issue.IsPull {
unittype = unit.TypePullRequests
}
for _, team := range teams {
if team.HasAdminAccess() {
checked = append(checked, team.ID)
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
continue
}
has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
if err != nil {
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
}
if has {
checked = append(checked, team.ID)
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
}
}
if len(checked) != 0 {
teamusers := make([]*user_model.User, 0, 20)
if err := db.GetEngine(ctx).
Join("INNER", "team_user", "team_user.uid = `user`.id").
In("`team_user`.team_id", checked).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
Find(&teamusers); err != nil {
return nil, fmt.Errorf("get teams users: %w", err)
}
if len(teamusers) > 0 {
users = make([]*user_model.User, 0, len(teamusers))
for _, user := range teamusers {
if already, ok := resolved[user.LowerName]; !ok || !already {
users = append(users, user)
resolved[user.LowerName] = true
}
}
}
}
}
}
// Remove names already in the list to avoid querying the database if pending names remain
mentionUsers := make([]string, 0, len(resolved))
for name, already := range resolved {
if !already {
mentionUsers = append(mentionUsers, name)
}
}
if len(mentionUsers) == 0 {
return users, err
}
if users == nil {
users = make([]*user_model.User, 0, len(mentionUsers))
}
unchecked := make([]*user_model.User, 0, len(mentionUsers))
if err := db.GetEngine(ctx).
Where("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
In("`user`.lower_name", mentionUsers).
Find(&unchecked); err != nil {
return nil, fmt.Errorf("find mentioned users: %w", err)
}
for _, user := range unchecked {
if already := resolved[user.LowerName]; already || user.IsOrganization() {
continue
}
// Normal users must have read access to the referencing issue
perm, err := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, user)
if err != nil {
return nil, fmt.Errorf("GetIndividualUserRepoPermission [%d]: %w", user.ID, err)
}
if !perm.CanReadIssuesOrPulls(issue.IsPull) {
continue
}
users = append(users, user)
}
return users, err
}
// UpdateIssuesMigrationsByType updates all migrated repositories' issues from gitServiceType to replace originalAuthorID to posterID
func UpdateIssuesMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error {
_, err := db.GetEngine(ctx).Table("issue").
Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
And("original_author_id = ?", originalAuthorID).
Update(map[string]any{
"poster_id": posterID,
"original_author": "",
"original_author_id": 0,
})
return err
}
// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID
func UpdateReactionsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error {
_, err := db.GetEngine(ctx).Table("reaction").
Where("original_author_id = ?", originalAuthorID).
And(migratedIssueCond(gitServiceType)).
Update(map[string]any{
"user_id": userID,
"original_author": "",
"original_author_id": 0,
})
return err
}
func GetOrphanedIssueRepoIDs(ctx context.Context) ([]int64, error) {
var repoIDs []int64
if err := db.GetEngine(ctx).Table("issue").Distinct("issue.repo_id").
Join("LEFT", "repository", "issue.repo_id=repository.id").
Where(builder.IsNull{"repository.id"}).
Find(&repoIDs); err != nil {
return nil, err
}
return repoIDs, nil
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
)
// IssueUser represents an issue-user relation.
type IssueUser struct {
ID int64 `xorm:"pk autoincr"`
UID int64 `xorm:"INDEX unique(uid_to_issue)"` // User ID.
IssueID int64 `xorm:"INDEX unique(uid_to_issue)"`
IsRead bool
IsMentioned bool
}
func init() {
db.RegisterModel(new(IssueUser))
}
// NewIssueUsers inserts an issue related users
func NewIssueUsers(ctx context.Context, repo *repo_model.Repository, issue *Issue) error {
assignees, err := repo_model.GetRepoAssignees(ctx, repo)
if err != nil {
return fmt.Errorf("getAssignees: %w", err)
}
// Poster can be anyone, append later if not one of assignees.
isPosterAssignee := false
// Leave a seat for poster itself to append later, but if poster is one of assignee
// and just waste 1 unit is cheaper than re-allocate memory once.
issueUsers := make([]*IssueUser, 0, len(assignees)+1)
for _, assignee := range assignees {
issueUsers = append(issueUsers, &IssueUser{
IssueID: issue.ID,
UID: assignee.ID,
})
isPosterAssignee = isPosterAssignee || assignee.ID == issue.PosterID
}
if !isPosterAssignee {
issueUsers = append(issueUsers, &IssueUser{
IssueID: issue.ID,
UID: issue.PosterID,
})
}
return db.Insert(ctx, issueUsers)
}
// UpdateIssueUserByRead updates issue-user relation for reading.
func UpdateIssueUserByRead(ctx context.Context, uid, issueID int64) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `issue_user` SET is_read=? WHERE uid=? AND issue_id=?", true, uid, issueID)
return err
}
// UpdateIssueUsersByMentions updates issue-user pairs by mentioning.
func UpdateIssueUsersByMentions(ctx context.Context, issueID int64, uids []int64) error {
for _, uid := range uids {
iu := &IssueUser{
UID: uid,
IssueID: issueID,
}
has, err := db.GetEngine(ctx).Get(iu)
if err != nil {
return err
}
iu.IsMentioned = true
if has {
_, err = db.GetEngine(ctx).ID(iu.ID).Cols("is_mentioned").Update(iu)
} else {
_, err = db.GetEngine(ctx).Insert(iu)
}
if err != nil {
return err
}
}
return nil
}
// GetIssueMentionIDs returns all mentioned user IDs of an issue.
func GetIssueMentionIDs(ctx context.Context, issueID int64) ([]int64, error) {
var ids []int64
return ids, db.GetEngine(ctx).Table(IssueUser{}).
Where("issue_id=?", issueID).
And("is_mentioned=?", true).
Select("uid").
Find(&ids)
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2017 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewIssueUsers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
newIssue := &issues_model.Issue{
RepoID: repo.ID,
PosterID: 4,
Index: 6,
Title: "newTestIssueTitle",
Content: "newTestIssueContent",
}
// artificially insert new issue
require.NoError(t, db.Insert(t.Context(), newIssue))
require.NoError(t, issues_model.NewIssueUsers(t.Context(), repo, newIssue))
// issue_user table should now have entries for new issue
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: newIssue.ID, UID: newIssue.PosterID})
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: newIssue.ID, UID: repo.OwnerID})
}
func TestUpdateIssueUserByRead(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.NoError(t, issues_model.UpdateIssueUserByRead(t.Context(), 4, issue.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: 4}, "is_read=1")
assert.NoError(t, issues_model.UpdateIssueUserByRead(t.Context(), 4, issue.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: 4}, "is_read=1")
assert.NoError(t, issues_model.UpdateIssueUserByRead(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
}
func TestUpdateIssueUsersByMentions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
uids := []int64{2, 5}
assert.NoError(t, issues_model.UpdateIssueUsersByMentions(t.Context(), issue.ID, uids))
for _, uid := range uids {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: uid}, "is_mentioned=1")
}
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil"
)
// IssueWatch is connection request for receiving issue notification.
type IssueWatch struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch) NOT NULL"`
IssueID int64 `xorm:"UNIQUE(watch) NOT NULL"`
IsWatching bool `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
}
func init() {
db.RegisterModel(new(IssueWatch))
}
// IssueWatchList contains IssueWatch
type IssueWatchList []*IssueWatch
// CreateOrUpdateIssueWatch set watching for a user and issue
func CreateOrUpdateIssueWatch(ctx context.Context, userID, issueID int64, isWatching bool) error {
iw, exists, err := GetIssueWatch(ctx, userID, issueID)
if err != nil {
return err
}
if !exists {
iw = &IssueWatch{
UserID: userID,
IssueID: issueID,
IsWatching: isWatching,
}
if _, err := db.GetEngine(ctx).Insert(iw); err != nil {
return err
}
} else {
iw.IsWatching = isWatching
if _, err := db.GetEngine(ctx).ID(iw.ID).Cols("is_watching", "updated_unix").Update(iw); err != nil {
return err
}
}
return nil
}
// GetIssueWatch returns all IssueWatch objects from db by user and issue
// the current Web-UI need iw object for watchers AND explicit non-watchers
func GetIssueWatch(ctx context.Context, userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
iw = new(IssueWatch)
exists, err = db.GetEngine(ctx).
Where("user_id = ?", userID).
And("issue_id = ?", issueID).
Get(iw)
return iw, exists, err
}
// CheckIssueWatch check if a user is watching an issue
// it takes participants and repo watch into account
func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (bool, error) {
iw, exist, err := GetIssueWatch(ctx, user.ID, issue.ID)
if err != nil {
return false, err
}
if exist {
return iw.IsWatching, nil
}
w, err := repo_model.GetWatch(ctx, user.ID, issue.RepoID)
if err != nil {
return false, err
}
return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(ctx, user, issue), nil
}
// GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id
// but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required
func GetIssueWatchersIDs(ctx context.Context, issueID int64, watching bool) ([]int64, error) {
ids := make([]int64, 0, 64)
return ids, db.GetEngine(ctx).Table("issue_watch").
Where("issue_id=?", issueID).
And("is_watching = ?", watching).
Select("user_id").
Find(&ids)
}
// GetIssueWatchers returns watchers/unwatchers of a given issue
func GetIssueWatchers(ctx context.Context, issueID int64, listOptions db.ListOptions) (IssueWatchList, error) {
sess := db.GetEngine(ctx).
Where("`issue_watch`.issue_id = ?", issueID).
And("`issue_watch`.is_watching = ?", true).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
Join("INNER", "`user`", "`user`.id = `issue_watch`.user_id")
if listOptions.Page > 0 {
db.SetSessionPagination(sess, &listOptions)
watches := make([]*IssueWatch, 0, listOptions.PageSize)
return watches, sess.Find(&watches)
}
watches := make([]*IssueWatch, 0, 8)
return watches, sess.Find(&watches)
}
// CountIssueWatchers count watchers/unwatchers of a given issue
func CountIssueWatchers(ctx context.Context, issueID int64) (int64, error) {
return db.GetEngine(ctx).
Where("`issue_watch`.issue_id = ?", issueID).
And("`issue_watch`.is_watching = ?", true).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
Join("INNER", "`user`", "`user`.id = `issue_watch`.user_id").Count(new(IssueWatch))
}
// RemoveIssueWatchersByRepoID remove issue watchers by repoID
func RemoveIssueWatchersByRepoID(ctx context.Context, userID, repoID int64) error {
_, err := db.GetEngine(ctx).
Join("INNER", "issue", "`issue`.id = `issue_watch`.issue_id AND `issue`.repo_id = ?", repoID).
Where("`issue_watch`.user_id = ?", userID).
Delete(new(IssueWatch))
return err
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestCreateOrUpdateIssueWatch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(t.Context(), 3, 1, true))
iw := unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 3, IssueID: 1})
assert.True(t, iw.IsWatching)
assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(t.Context(), 1, 1, false))
iw = unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 1, IssueID: 1})
assert.False(t, iw.IsWatching)
}
func TestGetIssueWatch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_, exists, err := issues_model.GetIssueWatch(t.Context(), 9, 1)
assert.True(t, exists)
assert.NoError(t, err)
iw, exists, err := issues_model.GetIssueWatch(t.Context(), 2, 2)
assert.True(t, exists)
assert.NoError(t, err)
assert.False(t, iw.IsWatching)
_, exists, err = issues_model.GetIssueWatch(t.Context(), 3, 1)
assert.False(t, exists)
assert.NoError(t, err)
}
func TestGetIssueWatchers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
iws, err := issues_model.GetIssueWatchers(t.Context(), 1, db.ListOptions{})
assert.NoError(t, err)
// Watcher is inactive, thus 0
assert.Empty(t, iws)
iws, err = issues_model.GetIssueWatchers(t.Context(), 2, db.ListOptions{})
assert.NoError(t, err)
// Watcher is explicit not watching
assert.Empty(t, iws)
iws, err = issues_model.GetIssueWatchers(t.Context(), 5, db.ListOptions{})
assert.NoError(t, err)
// Issue has no Watchers
assert.Empty(t, iws)
iws, err = issues_model.GetIssueWatchers(t.Context(), 7, db.ListOptions{})
assert.NoError(t, err)
// Issue has one watcher
assert.Len(t, iws, 1)
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/references"
)
type crossReference struct {
Issue *Issue
Action references.XRefAction
}
// crossReferencesContext is context to pass along findCrossReference functions
type crossReferencesContext struct {
Type CommentType
Doer *user_model.User
OrigIssue *Issue
OrigComment *Comment
RemoveOld bool
}
func findOldCrossReferences(ctx context.Context, issueID, commentID int64) ([]*Comment, error) {
active := make([]*Comment, 0, 10)
return active, db.GetEngine(ctx).Where("`ref_action` IN (?, ?, ?)", references.XRefActionNone, references.XRefActionCloses, references.XRefActionReopens).
And("`ref_issue_id` = ?", issueID).
And("`ref_comment_id` = ?", commentID).
Find(&active)
}
func neuterCrossReferences(ctx context.Context, issueID, commentID int64) error {
active, err := findOldCrossReferences(ctx, issueID, commentID)
if err != nil {
return err
}
ids := make([]int64, len(active))
for i, c := range active {
ids[i] = c.ID
}
return neuterCrossReferencesIDs(ctx, ids)
}
func neuterCrossReferencesIDs(ctx context.Context, ids []int64) error {
_, err := db.GetEngine(ctx).In("id", ids).Cols("`ref_action`").Update(&Comment{RefAction: references.XRefActionNeutered})
return err
}
// AddCrossReferences add cross repositories references.
func (issue *Issue) AddCrossReferences(stdCtx context.Context, doer *user_model.User, removeOld bool) error {
var commentType CommentType
if issue.IsPull {
commentType = CommentTypePullRef
} else {
commentType = CommentTypeIssueRef
}
ctx := &crossReferencesContext{
Type: commentType,
Doer: doer,
OrigIssue: issue,
RemoveOld: removeOld,
}
return issue.createCrossReferences(stdCtx, ctx, issue.Title, issue.Content)
}
func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossReferencesContext, plaincontent, mdcontent string) error {
xreflist, err := ctx.OrigIssue.getCrossReferences(stdCtx, ctx, plaincontent, mdcontent)
if err != nil {
return err
}
if ctx.RemoveOld {
var commentID int64
if ctx.OrigComment != nil {
commentID = ctx.OrigComment.ID
}
active, err := findOldCrossReferences(stdCtx, ctx.OrigIssue.ID, commentID)
if err != nil {
return err
}
ids := make([]int64, 0, len(active))
for _, c := range active {
found := false
for i, x := range xreflist {
if x.Issue.ID == c.IssueID && x.Action == c.RefAction {
found = true
xreflist = append(xreflist[:i], xreflist[i+1:]...)
break
}
}
if !found {
ids = append(ids, c.ID)
}
}
if len(ids) > 0 {
if err = neuterCrossReferencesIDs(stdCtx, ids); err != nil {
return err
}
}
}
for _, xref := range xreflist {
var refCommentID int64
if ctx.OrigComment != nil {
refCommentID = ctx.OrigComment.ID
}
opts := &CreateCommentOptions{
Type: ctx.Type,
Doer: ctx.Doer,
Repo: xref.Issue.Repo,
Issue: xref.Issue,
RefRepoID: ctx.OrigIssue.RepoID,
RefIssueID: ctx.OrigIssue.ID,
RefCommentID: refCommentID,
RefAction: xref.Action,
RefIsPull: ctx.OrigIssue.IsPull,
}
_, err := CreateComment(stdCtx, opts)
if err != nil {
return err
}
}
return nil
}
func (issue *Issue) getCrossReferences(stdCtx context.Context, ctx *crossReferencesContext, plaincontent, mdcontent string) ([]*crossReference, error) {
xreflist := make([]*crossReference, 0, 5)
var (
refRepo *repo_model.Repository
refIssue *Issue
refAction references.XRefAction
err error
)
allrefs := append(references.FindAllIssueReferences(plaincontent), references.FindAllIssueReferencesMarkdown(mdcontent)...)
for _, ref := range allrefs {
if ref.Owner == "" && ref.Name == "" {
// Issues in the same repository
if err := ctx.OrigIssue.LoadRepo(stdCtx); err != nil {
return nil, err
}
refRepo = ctx.OrigIssue.Repo
} else {
// Issues in other repositories
refRepo, err = repo_model.GetRepositoryByOwnerAndName(stdCtx, ref.Owner, ref.Name)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
continue
}
return nil, err
}
}
if refIssue, refAction, err = ctx.OrigIssue.verifyReferencedIssue(stdCtx, ctx, refRepo, ref); err != nil {
return nil, err
}
if refIssue != nil {
xreflist = ctx.OrigIssue.updateCrossReferenceList(xreflist, &crossReference{
Issue: refIssue,
Action: refAction,
})
}
}
return xreflist, nil
}
func (issue *Issue) updateCrossReferenceList(list []*crossReference, xref *crossReference) []*crossReference {
if xref.Issue.ID == issue.ID {
return list
}
for i, r := range list {
if r.Issue.ID == xref.Issue.ID {
if xref.Action != references.XRefActionNone {
list[i].Action = xref.Action
}
return list
}
}
return append(list, xref)
}
// verifyReferencedIssue will check if the referenced issue exists, and whether the doer has permission to do what
func (issue *Issue) verifyReferencedIssue(stdCtx context.Context, ctx *crossReferencesContext, repo *repo_model.Repository,
ref references.IssueReference,
) (*Issue, references.XRefAction, error) {
refIssue := &Issue{RepoID: repo.ID, Index: ref.Index}
refAction := ref.Action
e := db.GetEngine(stdCtx)
if has, _ := e.Get(refIssue); !has {
return nil, references.XRefActionNone, nil
}
if err := refIssue.LoadRepo(stdCtx); err != nil {
return nil, references.XRefActionNone, err
}
// Close/reopen actions can only be set from pull requests to issues
if refIssue.IsPull || !issue.IsPull {
refAction = references.XRefActionNone
}
// Check doer permissions; set action to None if the doer can't change the destination
if refIssue.RepoID != ctx.OrigIssue.RepoID || ref.Action != references.XRefActionNone {
perm, err := access_model.GetDoerRepoPermission(stdCtx, refIssue.Repo, ctx.Doer)
if err != nil {
return nil, references.XRefActionNone, err
}
if !perm.CanReadIssuesOrPulls(refIssue.IsPull) {
return nil, references.XRefActionNone, nil
}
if user_model.IsUserBlockedBy(stdCtx, ctx.Doer, refIssue.PosterID, refIssue.Repo.OwnerID) {
return nil, references.XRefActionNone, nil
}
// Accept close/reopening actions only if the poster is able to close the
// referenced issue manually at this moment. The only exception is
// the poster of a new PR referencing an issue on the same repo: then the merger
// should be responsible for checking whether the reference should resolve.
if ref.Action != references.XRefActionNone &&
ctx.Doer.ID != refIssue.PosterID &&
!perm.CanWriteIssuesOrPulls(refIssue.IsPull) &&
(refIssue.RepoID != ctx.OrigIssue.RepoID || ctx.OrigComment != nil) {
refAction = references.XRefActionNone
}
}
return refIssue, refAction, nil
}
// AddCrossReferences add cross references
func (c *Comment) AddCrossReferences(stdCtx context.Context, doer *user_model.User, removeOld bool) error {
if !c.Type.HasContentSupport() {
return nil
}
if err := c.LoadIssue(stdCtx); err != nil {
return err
}
ctx := &crossReferencesContext{
Type: CommentTypeCommentRef,
Doer: doer,
OrigIssue: c.Issue,
OrigComment: c,
RemoveOld: removeOld,
}
return c.Issue.createCrossReferences(stdCtx, ctx, "", c.Content)
}
func (c *Comment) neuterCrossReferences(ctx context.Context) error {
return neuterCrossReferences(ctx, c.IssueID, c.ID)
}
// LoadRefComment loads comment that created this reference from database
func (c *Comment) LoadRefComment(ctx context.Context) (err error) {
if c.RefComment != nil {
return nil
}
c.RefComment, err = GetCommentByID(ctx, c.RefCommentID)
return err
}
// LoadRefIssue loads comment that created this reference from database
func (c *Comment) LoadRefIssue(ctx context.Context) (err error) {
if c.RefIssue != nil {
return nil
}
c.RefIssue, err = GetIssueByID(ctx, c.RefIssueID)
if err == nil {
err = c.RefIssue.LoadRepo(ctx)
}
return err
}
// CommentTypeIsRef returns true if CommentType is a reference from another issue
func CommentTypeIsRef(t CommentType) bool {
return t == CommentTypeCommentRef || t == CommentTypePullRef || t == CommentTypeIssueRef
}
// RefCommentLink returns the relative URL for the comment that created this reference
func (c *Comment) RefCommentLink(ctx context.Context) string {
// Edge case for when the reference is inside the title or the description of the referring issue
if c.RefCommentID == 0 {
return c.RefIssueLink(ctx)
}
if err := c.LoadRefComment(ctx); err != nil { // Silently dropping errors :unamused:
log.Error("LoadRefComment(%d): %v", c.RefCommentID, err)
return ""
}
return c.RefComment.Link(ctx)
}
// RefIssueLink returns the relative URL of the issue where this reference was created
func (c *Comment) RefIssueLink(ctx context.Context) string {
if err := c.LoadRefIssue(ctx); err != nil { // Silently dropping errors :unamused:
log.Error("LoadRefIssue(%d): %v", c.RefCommentID, err)
return ""
}
return c.RefIssue.Link()
}
// RefIssueTitle returns the title of the issue where this reference was created
func (c *Comment) RefIssueTitle(ctx context.Context) string {
if err := c.LoadRefIssue(ctx); err != nil { // Silently dropping errors :unamused:
log.Error("LoadRefIssue(%d): %v", c.RefCommentID, err)
return ""
}
return c.RefIssue.Title
}
// RefIssueIdent returns the user friendly identity (e.g. "#1234") of the issue where this reference was created
func (c *Comment) RefIssueIdent(ctx context.Context) string {
if err := c.LoadRefIssue(ctx); err != nil { // Silently dropping errors :unamused:
log.Error("LoadRefIssue(%d): %v", c.RefCommentID, err)
return ""
}
// FIXME: check this name for cross-repository references (#7901 if it gets merged)
return fmt.Sprintf("#%d", c.RefIssue.Index)
}
// __________ .__ .__ __________ __
// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
// | | | | / |_| |_| | \ ___< <_| | | /\ ___/ \___ \ | |
// |____| |____/|____/____/____|_ /\___ >__ |____/ \___ >____ > |__|
// \/ \/ |__| \/ \/
// ResolveCrossReferences will return the list of references to close/reopen by this PR
func (pr *PullRequest) ResolveCrossReferences(ctx context.Context) ([]*Comment, error) {
unfiltered := make([]*Comment, 0, 5)
if err := db.GetEngine(ctx).
Where("ref_repo_id = ? AND ref_issue_id = ?", pr.Issue.RepoID, pr.Issue.ID).
In("ref_action", []references.XRefAction{references.XRefActionCloses, references.XRefActionReopens}).
OrderBy("id").
Find(&unfiltered); err != nil {
return nil, fmt.Errorf("get reference: %w", err)
}
refs := make([]*Comment, 0, len(unfiltered))
for _, ref := range unfiltered {
found := false
for i, r := range refs {
if r.IssueID == ref.IssueID {
// Keep only the latest
refs[i] = ref
found = true
break
}
}
if !found {
refs = append(refs, ref)
}
}
return refs, nil
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"fmt"
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/references"
"github.com/stretchr/testify/assert"
)
func TestXRef_AddCrossReferences(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Issue #1 to test against
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)
// PR to close issue #1
content := fmt.Sprintf("content2, closes #%d", itarget.Index)
pr := testCreateIssue(t, 1, 2, "title2", content, true)
ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypePullRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.True(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionCloses, ref.RefAction)
// Comment on PR to reopen issue #1
content = fmt.Sprintf("content2, reopens #%d", itarget.Index)
c := testCreateComment(t, 2, pr.ID, content)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: c.ID})
assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.True(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionReopens, ref.RefAction)
// Issue mentioning issue #1
content = fmt.Sprintf("content3, mentions #%d", itarget.Index)
i := testCreateIssue(t, 1, 2, "title3", content, false)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypeIssueRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.False(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionNone, ref.RefAction)
// Issue #4 to test against
itarget = testCreateIssue(t, 3, 3, "title4", "content4", false)
// Cross-reference to issue #4 by admin
content = fmt.Sprintf("content5, mentions org3/repo3#%d", itarget.Index)
i = testCreateIssue(t, 2, 1, "title5", content, false)
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypeIssueRef, ref.Type)
assert.Equal(t, i.RepoID, ref.RefRepoID)
assert.False(t, ref.RefIsPull)
assert.Equal(t, references.XRefActionNone, ref.RefAction)
// Cross-reference to issue #4 with no permission
content = fmt.Sprintf("content6, mentions org3/repo3#%d", itarget.Index)
i = testCreateIssue(t, 4, 5, "title6", content, false)
unittest.AssertNotExistsBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
}
func TestXRef_NeuterCrossReferences(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Issue #1 to test against
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)
// Issue mentioning issue #1
title := fmt.Sprintf("title2, mentions #%d", itarget.Index)
i := testCreateIssue(t, 1, 2, title, "content2", false)
ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypeIssueRef, ref.Type)
assert.Equal(t, references.XRefActionNone, ref.RefAction)
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
i.Title = "title2, no mentions"
assert.NoError(t, issues_model.ChangeIssueTitle(t.Context(), i, d, title))
ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
assert.Equal(t, issues_model.CommentTypeIssueRef, ref.Type)
assert.Equal(t, references.XRefActionNeutered, ref.RefAction)
}
func TestXRef_ResolveCrossReferences(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
i1 := testCreateIssue(t, 1, 2, "title1", "content1", false)
i2 := testCreateIssue(t, 1, 2, "title2", "content2", false)
i3 := testCreateIssue(t, 1, 2, "title3", "content3", false)
_, err := issues_model.CloseIssue(t.Context(), i3, d)
assert.NoError(t, err)
pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index))
rp := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: i1.ID, RefIssueID: pr.Issue.ID, RefCommentID: 0})
c1 := testCreateComment(t, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i2.Index))
r1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c1.ID})
// Must be ignored
c2 := testCreateComment(t, 2, pr.Issue.ID, fmt.Sprintf("mentions #%d", i2.Index))
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c2.ID})
// Must be superseded by c4/r4
c3 := testCreateComment(t, 2, pr.Issue.ID, fmt.Sprintf("reopens #%d", i3.Index))
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c3.ID})
c4 := testCreateComment(t, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i3.Index))
r4 := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c4.ID})
refs, err := pr.ResolveCrossReferences(t.Context())
assert.NoError(t, err)
assert.Len(t, refs, 3)
assert.Equal(t, rp.ID, refs[0].ID, "bad ref rp: %+v", refs[0])
assert.Equal(t, r1.ID, refs[1].ID, "bad ref r1: %+v", refs[1])
assert.Equal(t, r4.ID, refs[2].ID, "bad ref r4: %+v", refs[2])
}
func testCreateIssue(t *testing.T, repo, doer int64, title, content string, ispull bool) *issues_model.Issue {
r := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo})
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer})
ctx, committer, err := db.TxContext(t.Context())
assert.NoError(t, err)
defer committer.Close()
idx, err := db.GetNextResourceIndex(ctx, "issue_index", r.ID)
assert.NoError(t, err)
i := &issues_model.Issue{
RepoID: r.ID,
PosterID: d.ID,
Poster: d,
Title: title,
Content: content,
IsPull: ispull,
Index: idx,
}
err = issues_model.NewIssueWithIndex(ctx, d, issues_model.NewIssueOptions{
Repo: r,
Issue: i,
})
assert.NoError(t, err)
i, err = issues_model.GetIssueByID(ctx, i.ID)
assert.NoError(t, err)
assert.NoError(t, i.AddCrossReferences(ctx, d, false))
assert.NoError(t, committer.Commit())
return i
}
func testCreatePR(t *testing.T, repo, doer int64, title, content string) *issues_model.PullRequest {
r := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo})
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer})
i := &issues_model.Issue{RepoID: r.ID, PosterID: d.ID, Poster: d, Title: title, Content: content, IsPull: true}
pr := &issues_model.PullRequest{HeadRepoID: repo, BaseRepoID: repo, HeadBranch: "head", BaseBranch: "base", Status: issues_model.PullRequestStatusMergeable}
assert.NoError(t, issues_model.NewPullRequest(t.Context(), r, i, nil, nil, pr))
pr.Issue = i
return pr
}
func testCreateComment(t *testing.T, doer, issue int64, content string) *issues_model.Comment {
d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer})
i := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue})
c := &issues_model.Comment{Type: issues_model.CommentTypeComment, PosterID: doer, Poster: d, IssueID: issue, Issue: i, Content: content}
ctx, committer, err := db.TxContext(t.Context())
assert.NoError(t, err)
defer committer.Close()
err = db.Insert(ctx, c)
assert.NoError(t, err)
assert.NoError(t, c.AddCrossReferences(ctx, d, false))
assert.NoError(t, committer.Commit())
return c
}
+513
View File
@@ -0,0 +1,513 @@
// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"gitea.dev/models/db"
"gitea.dev/modules/container"
"gitea.dev/modules/label"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ErrRepoLabelNotExist represents a "RepoLabelNotExist" kind of error.
type ErrRepoLabelNotExist struct {
LabelID int64
RepoID int64
}
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
func IsErrRepoLabelNotExist(err error) bool {
_, ok := err.(ErrRepoLabelNotExist)
return ok
}
func (err ErrRepoLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
}
func (err ErrRepoLabelNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrOrgLabelNotExist represents a "OrgLabelNotExist" kind of error.
type ErrOrgLabelNotExist struct {
LabelID int64
OrgID int64
}
// IsErrOrgLabelNotExist checks if an error is a OrgErrLabelNotExist.
func IsErrOrgLabelNotExist(err error) bool {
_, ok := err.(ErrOrgLabelNotExist)
return ok
}
func (err ErrOrgLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, org_id: %d]", err.LabelID, err.OrgID)
}
func (err ErrOrgLabelNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrLabelNotExist represents a "LabelNotExist" kind of error.
type ErrLabelNotExist struct {
LabelID int64
}
// IsErrLabelNotExist checks if an error is a ErrLabelNotExist.
func IsErrLabelNotExist(err error) bool {
_, ok := err.(ErrLabelNotExist)
return ok
}
func (err ErrLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d]", err.LabelID)
}
func (err ErrLabelNotExist) Unwrap() error {
return util.ErrNotExist
}
// Label represents a label of repository for issues.
type Label struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
OrgID int64 `xorm:"INDEX"`
Name string
Exclusive bool
ExclusiveOrder int `xorm:"DEFAULT 0"` // 0 means no exclusive order
Description string
Color string `xorm:"VARCHAR(7)"`
NumIssues int
NumClosedIssues int
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
NumOpenIssues int `xorm:"-"`
NumOpenRepoIssues int64 `xorm:"-"`
IsChecked bool `xorm:"-"`
QueryString string `xorm:"-"`
IsSelected bool `xorm:"-"`
IsExcluded bool `xorm:"-"`
ArchivedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
}
func init() {
db.RegisterModel(new(Label))
db.RegisterModel(new(IssueLabel))
}
// CalOpenIssues sets the number of open issues of a label based on the already stored number of closed issues.
func (l *Label) CalOpenIssues() {
l.NumOpenIssues = l.NumIssues - l.NumClosedIssues
}
// SetArchived set the label as archived
func (l *Label) SetArchived(isArchived bool) {
if !isArchived {
l.ArchivedUnix = timeutil.TimeStamp(0)
} else if isArchived && !l.IsArchived() {
// Only change the date when it is newly archived.
l.ArchivedUnix = timeutil.TimeStampNow()
}
}
// IsArchived returns true if label is an archived
func (l *Label) IsArchived() bool {
return !l.ArchivedUnix.IsZero()
}
// 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{
RepoIDs: []int64{repoID},
LabelIDs: []int64{labelID},
IsClosed: optional.Some(false),
})
for _, count := range counts {
l.NumOpenRepoIssues += count
}
}
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
labelQueryParams := container.Set[string]{}
labelSelected := false
exclusiveScope := l.ExclusiveScope()
for i, curSel := range currentSelectedLabels {
if curSel == l.ID {
labelSelected = true
} else if -curSel == l.ID {
labelSelected = true
l.IsExcluded = true
} else if curSel != 0 {
// Exclude other labels in the same scope from selection
if curSel < 0 || exclusiveScope == "" || exclusiveScope != currentSelectedExclusiveScopes[i] {
labelQueryParams.Add(strconv.FormatInt(curSel, 10))
}
}
}
if !labelSelected {
labelQueryParams.Add(strconv.FormatInt(l.ID, 10))
}
l.IsSelected = labelSelected
// Sort and deduplicate the ids to avoid the crawlers asking for the
// same thing with simply a different order of parameters
labelQuerySliceStrings := labelQueryParams.Values()
slices.Sort(labelQuerySliceStrings) // the sort is still needed because the underlying map of Set doesn't guarantee order
l.QueryString = strings.Join(labelQuerySliceStrings, ",")
}
// BelongsToOrg returns true if label is an organization label
func (l *Label) BelongsToOrg() bool {
return l.OrgID > 0
}
// BelongsToRepo returns true if label is a repository label
func (l *Label) BelongsToRepo() bool {
return l.RepoID > 0
}
// ExclusiveScope returns scope substring of label name, or empty string if none exists
func (l *Label) ExclusiveScope() string {
if !l.Exclusive {
return ""
}
lastIndex := strings.LastIndex(l.Name, "/")
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(l.Name)-1 {
return ""
}
return l.Name[:lastIndex]
}
// NewLabel creates a new label
func NewLabel(ctx context.Context, l *Label) error {
color, err := label.NormalizeColor(l.Color)
if err != nil {
return err
}
l.Color = color
return db.Insert(ctx, l)
}
// NewLabels creates new labels
func NewLabels(ctx context.Context, labels ...*Label) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for _, l := range labels {
color, err := label.NormalizeColor(l.Color)
if err != nil {
return err
}
l.Color = color
if err := db.Insert(ctx, l); err != nil {
return err
}
}
return nil
})
}
// UpdateLabel updates label information.
func UpdateLabel(ctx context.Context, l *Label) error {
color, err := label.NormalizeColor(l.Color)
if err != nil {
return err
}
l.Color = color
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
}
// DeleteLabel delete a label
func DeleteLabel(ctx context.Context, id, labelID int64) error {
l, err := GetLabelByID(ctx, labelID)
if err != nil {
if IsErrLabelNotExist(err) {
return nil
}
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
if l.BelongsToOrg() && l.OrgID != id {
return nil
}
if l.BelongsToRepo() && l.RepoID != id {
return nil
}
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
return err
} else if _, err = db.GetEngine(ctx).
Where("label_id = ?", labelID).
Delete(new(IssueLabel)); err != nil {
return err
}
// delete comments about now deleted label_id
_, err = db.GetEngine(ctx).Where("label_id = ?", labelID).Cols("label_id").Delete(&Comment{})
return err
})
}
// GetLabelByID returns a label by given ID.
func GetLabelByID(ctx context.Context, labelID int64) (*Label, error) {
if labelID <= 0 {
return nil, ErrLabelNotExist{labelID}
}
l := &Label{}
has, err := db.GetEngine(ctx).ID(labelID).Get(l)
if err != nil {
return nil, err
} else if !has {
return nil, ErrLabelNotExist{l.ID}
}
return l, nil
}
// GetLabelsByIDs returns a list of labels by IDs
func GetLabelsByIDs(ctx context.Context, labelIDs []int64, cols ...string) ([]*Label, error) {
labels := make([]*Label, 0, len(labelIDs))
if len(labelIDs) == 0 {
return labels, nil
}
return labels, db.GetEngine(ctx).Table("label").
In("id", labelIDs).
Asc("name").
Cols(cols...).
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 {
return nil, ErrRepoLabelNotExist{0, repoID}
}
l, exist, err := db.Get[Label](ctx, builder.Eq{"name": labelName, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrRepoLabelNotExist{0, repoID}
}
return l, nil
}
// GetLabelInRepoByID returns a label by ID in given repository.
func GetLabelInRepoByID(ctx context.Context, repoID, labelID int64) (*Label, error) {
if labelID <= 0 || repoID <= 0 {
return nil, ErrRepoLabelNotExist{labelID, repoID}
}
l, exist, err := db.Get[Label](ctx, builder.Eq{"id": labelID, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrRepoLabelNotExist{labelID, repoID}
}
return l, nil
}
// GetLabelIDsInRepoByNames returns a list of labelIDs by names in a given
// repository.
// it silently ignores label names that do not belong to the repository.
func GetLabelIDsInRepoByNames(ctx context.Context, repoID int64, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, db.GetEngine(ctx).Table("label").
Where("repo_id = ?", repoID).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// GetLabelIDsInOrgByNames returns a list of labelIDs by names in a given org.
func GetLabelIDsInOrgByNames(ctx context.Context, orgID int64, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, db.GetEngine(ctx).Table("label").
Where("org_id = ?", orgID).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// BuildLabelNamesIssueIDsCondition returns a builder where get issue ids match label names
func BuildLabelNamesIssueIDsCondition(labelNames []string) *builder.Builder {
return builder.Select("issue_label.issue_id").
From("issue_label").
InnerJoin("label", "label.id = issue_label.label_id").
Where(
builder.In("label.name", labelNames),
).
GroupBy("issue_label.issue_id")
}
// GetLabelsInRepoByIDs returns a list of labels by IDs in given repository,
// it silently ignores label IDs that do not belong to the repository.
func GetLabelsInRepoByIDs(ctx context.Context, repoID int64, labelIDs []int64) ([]*Label, error) {
labels := make([]*Label, 0, len(labelIDs))
if len(labelIDs) == 0 {
return labels, nil
}
return labels, db.GetEngine(ctx).
Where("repo_id = ?", repoID).
In("id", labelIDs).
Asc("name").
Find(&labels)
}
// GetLabelsByRepoID returns all labels that belong to given repository by ID.
func GetLabelsByRepoID(ctx context.Context, repoID int64, sortType string, listOptions db.ListOptions) ([]*Label, error) {
if repoID <= 0 {
return nil, ErrRepoLabelNotExist{0, repoID}
}
labels := make([]*Label, 0, 10)
sess := db.GetEngine(ctx).Where("repo_id = ?", repoID)
switch sortType {
case "reversealphabetically":
sess.Desc("name")
case "leastissues":
sess.Asc("num_issues")
case "mostissues":
sess.Desc("num_issues")
default:
sess.Asc("name")
}
if listOptions.Page > 0 {
db.SetSessionPagination(sess, &listOptions)
}
return labels, sess.Find(&labels)
}
// CountLabelsByRepoID count number of all labels that belong to given repository by ID.
func CountLabelsByRepoID(ctx context.Context, repoID int64) (int64, error) {
return db.GetEngine(ctx).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 {
return nil, ErrOrgLabelNotExist{0, orgID}
}
l, exist, err := db.Get[Label](ctx, builder.Eq{"name": labelName, "org_id": orgID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrOrgLabelNotExist{0, orgID}
}
return l, nil
}
// GetLabelInOrgByID returns a label by ID in given organization.
func GetLabelInOrgByID(ctx context.Context, orgID, labelID int64) (*Label, error) {
if labelID <= 0 || orgID <= 0 {
return nil, ErrOrgLabelNotExist{labelID, orgID}
}
l, exist, err := db.Get[Label](ctx, builder.Eq{"id": labelID, "org_id": orgID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrOrgLabelNotExist{labelID, orgID}
}
return l, nil
}
// GetLabelsInOrgByIDs returns a list of labels by IDs in given organization,
// it silently ignores label IDs that do not belong to the organization.
func GetLabelsInOrgByIDs(ctx context.Context, orgID int64, labelIDs []int64) ([]*Label, error) {
labels := make([]*Label, 0, len(labelIDs))
if len(labelIDs) == 0 {
return labels, nil
}
return labels, db.GetEngine(ctx).
Where("org_id = ?", orgID).
In("id", labelIDs).
Asc("name").
Find(&labels)
}
// GetLabelsByOrgID returns all labels that belong to given organization by ID.
func GetLabelsByOrgID(ctx context.Context, orgID int64, sortType string, listOptions db.ListOptions) ([]*Label, error) {
if orgID <= 0 {
return nil, ErrOrgLabelNotExist{0, orgID}
}
labels := make([]*Label, 0, 10)
sess := db.GetEngine(ctx).Where("org_id = ?", orgID)
switch sortType {
case "reversealphabetically":
sess.Desc("name")
case "leastissues":
sess.Asc("num_issues")
case "mostissues":
sess.Desc("num_issues")
default:
sess.Asc("name")
}
if listOptions.Page > 0 {
db.SetSessionPagination(sess, &listOptions)
}
return labels, sess.Find(&labels)
}
// GetLabelIDsByNames returns a list of labelIDs by names.
// It doesn't filter them by repo or org, so it could return labels belonging to different repos/orgs.
// It's used for filtering issues via indexer, otherwise it would be useless.
// Since it could return labels with the same name, so the length of returned ids could be more than the length of names.
func GetLabelIDsByNames(ctx context.Context, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, db.GetEngine(ctx).Table("label").
In("name", labelNames).
Cols("id").
Find(&labelIDs)
}
// CountLabelsByOrgID count all labels that belong to given organization by ID.
func CountLabelsByOrgID(ctx context.Context, orgID int64) (int64, error) {
return db.GetEngine(ctx).Where("org_id = ?", orgID).Count(&Label{})
}
func updateLabelCols(ctx context.Context, l *Label, cols ...string) error {
_, err := db.GetEngine(ctx).ID(l.ID).
SetExpr("num_issues",
builder.Select("count(*)").From("issue_label").
Where(builder.Eq{"label_id": l.ID}),
).
SetExpr("num_closed_issues",
builder.Select("count(*)").From("issue_label").
InnerJoin("issue", "issue_label.issue_id = issue.id").
Where(builder.Eq{
"issue_label.label_id": l.ID,
"issue.is_closed": true,
}),
).
Cols(cols...).Update(l)
return err
}
+419
View File
@@ -0,0 +1,419 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestLabel_CalOpenIssues(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
label.CalOpenIssues()
assert.Equal(t, 2, label.NumOpenIssues)
}
func TestLabel_LoadSelectedLabelsAfterClick(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Loading the label id:8 which have a scope and an exclusivity
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
// First test : with negative and scope
label.LoadSelectedLabelsAfterClick([]int64{1, -8}, []string{"", "scope"})
assert.Equal(t, "1", label.QueryString)
assert.True(t, label.IsSelected)
// Second test : with duplicates
label.LoadSelectedLabelsAfterClick([]int64{1, 7, 1, 7, 7}, []string{"", "scope", "", "scope", "scope"})
assert.Equal(t, "1,8", label.QueryString)
assert.False(t, label.IsSelected)
// Third test : empty set
label.LoadSelectedLabelsAfterClick([]int64{}, []string{})
assert.False(t, label.IsSelected)
assert.Equal(t, "8", label.QueryString)
}
func TestLabel_ExclusiveScope(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
assert.Equal(t, "scope", label.ExclusiveScope())
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9})
assert.Equal(t, "scope/subscope", label.ExclusiveScope())
}
func TestNewLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labels := []*issues_model.Label{
{RepoID: 2, Name: "labelName2", Color: "#123456"},
{RepoID: 3, Name: "labelName3", Color: "#123"},
{RepoID: 4, Name: "labelName4", Color: "ABCDEF"},
{RepoID: 5, Name: "labelName5", Color: "DEF"},
}
assert.Error(t, issues_model.NewLabel(t.Context(), &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: ""}))
assert.Error(t, issues_model.NewLabel(t.Context(), &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "#45G"}))
assert.Error(t, issues_model.NewLabel(t.Context(), &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "#12345G"}))
assert.Error(t, issues_model.NewLabel(t.Context(), &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "45G"}))
assert.Error(t, issues_model.NewLabel(t.Context(), &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "12345G"}))
for _, label := range labels {
unittest.AssertNotExistsBean(t, label)
}
assert.NoError(t, issues_model.NewLabels(t.Context(), labels...))
for _, label := range labels {
unittest.AssertExistsAndLoadBean(t, label, unittest.Cond("id = ?", label.ID))
}
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
}
func TestGetLabelByID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label, err := issues_model.GetLabelByID(t.Context(), 1)
assert.NoError(t, err)
assert.EqualValues(t, 1, label.ID)
_, err = issues_model.GetLabelByID(t.Context(), unittest.NonexistentID)
assert.True(t, issues_model.IsErrLabelNotExist(err))
}
func TestGetLabelInRepoByName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label, err := issues_model.GetLabelInRepoByName(t.Context(), 1, "label1")
assert.NoError(t, err)
assert.EqualValues(t, 1, label.ID)
assert.Equal(t, "label1", label.Name)
_, err = issues_model.GetLabelInRepoByName(t.Context(), 1, "")
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
_, err = issues_model.GetLabelInRepoByName(t.Context(), unittest.NonexistentID, "nonexistent")
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
}
func TestGetLabelInRepoByNames(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labelIDs, err := issues_model.GetLabelIDsInRepoByNames(t.Context(), 1, []string{"label1", "label2"})
assert.NoError(t, err)
assert.Len(t, labelIDs, 2)
assert.Equal(t, int64(1), labelIDs[0])
assert.Equal(t, int64(2), labelIDs[1])
}
func TestGetLabelInRepoByNamesDiscardsNonExistentLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// label3 doesn't exists.. See labels.yml
labelIDs, err := issues_model.GetLabelIDsInRepoByNames(t.Context(), 1, []string{"label1", "label2", "label3"})
assert.NoError(t, err)
assert.Len(t, labelIDs, 2)
assert.Equal(t, int64(1), labelIDs[0])
assert.Equal(t, int64(2), labelIDs[1])
assert.NoError(t, err)
}
func TestGetLabelInRepoByID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label, err := issues_model.GetLabelInRepoByID(t.Context(), 1, 1)
assert.NoError(t, err)
assert.EqualValues(t, 1, label.ID)
_, err = issues_model.GetLabelInRepoByID(t.Context(), 1, -1)
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
_, err = issues_model.GetLabelInRepoByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
}
func TestGetLabelsInRepoByIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labels, err := issues_model.GetLabelsInRepoByIDs(t.Context(), 1, []int64{1, 2, unittest.NonexistentID})
assert.NoError(t, err)
if assert.Len(t, labels, 2) {
assert.EqualValues(t, 1, labels[0].ID)
assert.EqualValues(t, 2, labels[1].ID)
}
}
func TestGetLabelsByRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(repoID int64, sortType string, expectedIssueIDs []int64) {
labels, err := issues_model.GetLabelsByRepoID(t.Context(), repoID, sortType, db.ListOptions{})
assert.NoError(t, err)
assert.Len(t, labels, len(expectedIssueIDs))
for i, label := range labels {
assert.Equal(t, expectedIssueIDs[i], label.ID)
}
}
testSuccess(1, "leastissues", []int64{2, 1})
testSuccess(1, "mostissues", []int64{1, 2})
testSuccess(1, "reversealphabetically", []int64{2, 1})
testSuccess(1, "default", []int64{1, 2})
}
// Org versions
func TestGetLabelInOrgByName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label, err := issues_model.GetLabelInOrgByName(t.Context(), 3, "orglabel3")
assert.NoError(t, err)
assert.EqualValues(t, 3, label.ID)
assert.Equal(t, "orglabel3", label.Name)
_, err = issues_model.GetLabelInOrgByName(t.Context(), 3, "")
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByName(t.Context(), 0, "orglabel3")
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByName(t.Context(), -1, "orglabel3")
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByName(t.Context(), unittest.NonexistentID, "nonexistent")
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
}
func TestGetLabelInOrgByID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label, err := issues_model.GetLabelInOrgByID(t.Context(), 3, 3)
assert.NoError(t, err)
assert.EqualValues(t, 3, label.ID)
_, err = issues_model.GetLabelInOrgByID(t.Context(), 3, -1)
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByID(t.Context(), 0, 3)
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByID(t.Context(), -1, 3)
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelInOrgByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
}
func TestGetLabelsInOrgByIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labels, err := issues_model.GetLabelsInOrgByIDs(t.Context(), 3, []int64{3, 4, unittest.NonexistentID})
assert.NoError(t, err)
if assert.Len(t, labels, 2) {
assert.EqualValues(t, 3, labels[0].ID)
assert.EqualValues(t, 4, labels[1].ID)
}
}
func TestGetLabelsByOrgID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(orgID int64, sortType string, expectedIssueIDs []int64) {
labels, err := issues_model.GetLabelsByOrgID(t.Context(), orgID, sortType, db.ListOptions{})
assert.NoError(t, err)
assert.Len(t, labels, len(expectedIssueIDs))
for i, label := range labels {
assert.Equal(t, expectedIssueIDs[i], label.ID)
}
}
testSuccess(3, "leastissues", []int64{3, 4})
testSuccess(3, "mostissues", []int64{4, 3})
testSuccess(3, "reversealphabetically", []int64{4, 3})
testSuccess(3, "default", []int64{3, 4})
_, err := issues_model.GetLabelsByOrgID(t.Context(), 0, "leastissues", db.ListOptions{})
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
_, err = issues_model.GetLabelsByOrgID(t.Context(), -1, "leastissues", db.ListOptions{})
assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
}
//
func TestGetLabelsByIssueID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labels, err := issues_model.GetLabelsByIssueID(t.Context(), 1)
assert.NoError(t, err)
if assert.Len(t, labels, 1) {
assert.EqualValues(t, 1, labels[0].ID)
}
labels, err = issues_model.GetLabelsByIssueID(t.Context(), unittest.NonexistentID)
assert.NoError(t, err)
assert.Empty(t, labels)
}
func TestUpdateLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
// make sure update won't overwrite it
update := &issues_model.Label{
ID: label.ID,
Color: "#ffff00",
Name: "newLabelName",
Description: label.Description,
Exclusive: false,
ArchivedUnix: timeutil.TimeStamp(0),
}
label.Color = update.Color
label.Name = update.Name
assert.NoError(t, issues_model.UpdateLabel(t.Context(), update))
newLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
assert.Equal(t, label.ID, newLabel.ID)
assert.Equal(t, label.Color, newLabel.Color)
assert.Equal(t, label.Name, newLabel.Name)
assert.Equal(t, label.Description, newLabel.Description)
assert.EqualValues(t, 0, newLabel.ArchivedUnix)
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
}
func TestDeleteLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
assert.NoError(t, issues_model.DeleteLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
}
func TestHasIssueLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, issues_model.HasIssueLabel(t.Context(), 1, 1))
assert.False(t, issues_model.HasIssueLabel(t.Context(), 1, 2))
assert.False(t, issues_model.HasIssueLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
}
func TestNewIssueLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// add new IssueLabel
prevNumIssues := label.NumIssues
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, label, doer))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
Type: issues_model.CommentTypeLabel,
PosterID: doer.ID,
IssueID: issue.ID,
LabelID: label.ID,
Content: "1",
})
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
assert.Equal(t, prevNumIssues+1, label.NumIssues)
// re-add existing IssueLabel
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, label, doer))
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
}
func TestNewIssueExclusiveLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6})
exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
// coexisting regular and exclusive label
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, otherLabel, doer))
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, exclusiveLabelA, doer))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
// exclusive label replaces existing one
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, exclusiveLabelB, doer))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
// exclusive label replaces existing one again
assert.NoError(t, issues_model.NewIssueLabel(t.Context(), issue, exclusiveLabelA, doer))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
}
func TestNewIssueLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
label2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 5})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, issues_model.NewIssueLabels(t.Context(), issue, []*issues_model.Label{label1, label2}, doer))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label1.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
Type: issues_model.CommentTypeLabel,
PosterID: doer.ID,
IssueID: issue.ID,
LabelID: label1.ID,
Content: "1",
})
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label1.ID})
label1 = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
assert.Equal(t, 3, label1.NumIssues)
assert.Equal(t, 1, label1.NumClosedIssues)
label2 = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
assert.Equal(t, 1, label2.NumIssues)
assert.Equal(t, 1, label2.NumClosedIssues)
// corner case: test empty slice
assert.NoError(t, issues_model.NewIssueLabels(t.Context(), issue, []*issues_model.Label{}, doer))
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
}
func TestDeleteIssueLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(labelID, issueID, doerID int64) {
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doerID})
expectedNumIssues := label.NumIssues
expectedNumClosedIssues := label.NumClosedIssues
if unittest.GetBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID}) != nil {
expectedNumIssues--
if issue.IsClosed {
expectedNumClosedIssues--
}
}
ctx, committer, err := db.TxContext(t.Context())
defer committer.Close()
assert.NoError(t, err)
assert.NoError(t, issues_model.DeleteIssueLabel(ctx, issue, label, doer))
assert.NoError(t, committer.Commit())
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
Type: issues_model.CommentTypeLabel,
PosterID: doerID,
IssueID: issueID,
LabelID: labelID,
}, `content=''`)
label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID})
assert.Equal(t, expectedNumIssues, label.NumIssues)
assert.Equal(t, expectedNumClosedIssues, label.NumClosedIssues)
}
testSuccess(1, 1, 2)
testSuccess(2, 5, 2)
testSuccess(1, 1, 2) // delete non-existent IssueLabel
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
_ "gitea.dev/models"
_ "gitea.dev/models/actions"
_ "gitea.dev/models/activities"
_ "gitea.dev/models/repo"
_ "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestFixturesAreConsistent(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
unittest.CheckConsistencyFor(t,
&issues_model.Issue{},
&issues_model.PullRequest{},
&issues_model.Milestone{},
&issues_model.Label{},
)
}
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+349
View File
@@ -0,0 +1,349 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"html/template"
"strings"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/optional"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ErrMilestoneNotExist represents a "MilestoneNotExist" kind of error.
type ErrMilestoneNotExist struct {
ID int64
RepoID int64
Name string
}
// IsErrMilestoneNotExist checks if an error is a ErrMilestoneNotExist.
func IsErrMilestoneNotExist(err error) bool {
_, ok := err.(ErrMilestoneNotExist)
return ok
}
func (err ErrMilestoneNotExist) Error() string {
if len(err.Name) > 0 {
return fmt.Sprintf("milestone does not exist [name: %s, repo_id: %d]", err.Name, err.RepoID)
}
return fmt.Sprintf("milestone does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID)
}
func (err ErrMilestoneNotExist) Unwrap() error {
return util.ErrNotExist
}
// Milestone represents a milestone of repository.
type Milestone struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Repo *repo_model.Repository `xorm:"-"`
Name string
Content string `xorm:"TEXT"`
RenderedContent template.HTML `xorm:"-"`
IsClosed bool
NumIssues int
NumClosedIssues int
NumOpenIssues int `xorm:"-"`
Completeness int // Percentage(1-100).
IsOverdue bool `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
DeadlineUnix timeutil.TimeStamp
ClosedDateUnix timeutil.TimeStamp
DeadlineString string `xorm:"-"`
TotalTrackedTime int64 `xorm:"-"`
}
func init() {
db.RegisterModel(new(Milestone))
}
// BeforeUpdate is invoked from XORM before updating this object.
func (m *Milestone) BeforeUpdate() {
if m.NumIssues > 0 {
m.Completeness = m.NumClosedIssues * 100 / m.NumIssues
} else if m.IsClosed {
m.Completeness = 100
} else {
m.Completeness = 0
}
}
// AfterLoad is invoked from XORM after setting the value of a field of
// this object.
func (m *Milestone) AfterLoad() {
m.NumOpenIssues = m.NumIssues - m.NumClosedIssues
if m.DeadlineUnix == 0 {
return
}
m.DeadlineString = m.DeadlineUnix.FormatDate()
if m.IsClosed {
m.IsOverdue = m.ClosedDateUnix >= m.DeadlineUnix
} else {
m.IsOverdue = timeutil.TimeStampNow() >= m.DeadlineUnix
}
}
// State returns string representation of milestone status.
func (m *Milestone) State() api.StateType {
if m.IsClosed {
return api.StateClosed
}
return api.StateOpen
}
// NewMilestone creates new milestone of repository.
func NewMilestone(ctx context.Context, m *Milestone) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
m.Name = strings.TrimSpace(m.Name)
if err = db.Insert(ctx, m); err != nil {
return err
}
_, err = db.Exec(ctx, "UPDATE `repository` SET num_milestones = num_milestones + 1 WHERE id = ?", m.RepoID)
return err
})
}
// HasMilestoneByRepoID returns if the milestone exists in the repository.
func HasMilestoneByRepoID(ctx context.Context, repoID, id int64) (bool, error) {
return db.GetEngine(ctx).ID(id).Where("repo_id=?", repoID).Exist(new(Milestone))
}
// GetMilestoneByRepoID returns the milestone in a repository.
func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, error) {
m := new(Milestone)
has, err := db.GetEngine(ctx).ID(id).Where("repo_id=?", repoID).Get(m)
if err != nil {
return nil, err
} else if !has {
return nil, ErrMilestoneNotExist{ID: id, RepoID: repoID}
}
return m, nil
}
// GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo
func GetMilestoneByRepoIDANDName(ctx context.Context, repoID int64, name string) (*Milestone, error) {
var mile Milestone
has, err := db.GetEngine(ctx).Where("repo_id=? AND name=?", repoID, name).Get(&mile)
if err != nil {
return nil, err
}
if !has {
return nil, ErrMilestoneNotExist{Name: name, RepoID: repoID}
}
return &mile, nil
}
// UpdateMilestone updates information of given milestone.
func UpdateMilestone(ctx context.Context, m *Milestone, oldIsClosed bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if m.IsClosed && !oldIsClosed {
m.ClosedDateUnix = timeutil.TimeStampNow()
}
if err := updateMilestone(ctx, m); err != nil {
return err
}
// if IsClosed changed, update milestone numbers of repository
if oldIsClosed != m.IsClosed {
if err := updateRepoMilestoneNum(ctx, m.RepoID); err != nil {
return err
}
}
return nil
})
}
func updateMilestone(ctx context.Context, m *Milestone) error {
m.Name = strings.TrimSpace(m.Name)
_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
if err != nil {
return err
}
return UpdateMilestoneCounters(ctx, m.ID)
}
// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness
func UpdateMilestoneCounters(ctx context.Context, id int64) error {
e := db.GetEngine(ctx)
_, err := e.ID(id).
Cols("num_issues", "num_closed_issues").
SetExpr("num_issues", builder.Select("count(*)").From("issue").Where(
builder.Eq{"milestone_id": id},
)).
SetExpr("num_closed_issues", builder.Select("count(*)").From("issue").Where(
builder.Eq{
"milestone_id": id,
"is_closed": true,
},
)).
Update(&Milestone{})
if err != nil {
return err
}
_, err = e.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END) WHERE id=?",
true, id,
)
return err
}
// ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo.
func ChangeMilestoneStatusByRepoIDAndID(ctx context.Context, repoID, milestoneID int64, isClosed bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
m := &Milestone{
ID: milestoneID,
RepoID: repoID,
}
has, err := db.GetEngine(ctx).ID(milestoneID).Where("repo_id = ?", repoID).Get(m)
if err != nil {
return err
} else if !has {
return ErrMilestoneNotExist{ID: milestoneID, RepoID: repoID}
}
return changeMilestoneStatus(ctx, m, isClosed)
})
}
// ChangeMilestoneStatus changes the milestone open/closed status.
func ChangeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
return changeMilestoneStatus(ctx, m, isClosed)
})
}
func changeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) error {
m.IsClosed = isClosed
if isClosed {
m.ClosedDateUnix = timeutil.TimeStampNow()
}
count, err := db.GetEngine(ctx).ID(m.ID).Where("repo_id = ? AND is_closed = ?", m.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(m)
if err != nil {
return err
}
if count < 1 {
return nil
}
if err := UpdateMilestoneCounters(ctx, m.ID); err != nil {
return err
}
return updateRepoMilestoneNum(ctx, m.RepoID)
}
// DeleteMilestoneByRepoID deletes a milestone from a repository.
func DeleteMilestoneByRepoID(ctx context.Context, repoID, id int64) error {
m, err := GetMilestoneByRepoID(ctx, repoID, id)
if err != nil {
if IsErrMilestoneNotExist(err) {
return nil
}
return err
}
repo, err := repo_model.GetRepositoryByID(ctx, m.RepoID)
if err != nil {
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
if _, err = db.DeleteByID[Milestone](ctx, m.ID); err != nil {
return err
}
numMilestones, err := db.Count[Milestone](ctx, FindMilestoneOptions{
RepoID: repo.ID,
})
if err != nil {
return err
}
numClosedMilestones, err := db.Count[Milestone](ctx, FindMilestoneOptions{
RepoID: repo.ID,
IsClosed: optional.Some(true),
})
if err != nil {
return err
}
repo.NumMilestones = int(numMilestones)
repo.NumClosedMilestones = int(numClosedMilestones)
if _, err = db.GetEngine(ctx).ID(repo.ID).Cols("num_milestones, num_closed_milestones").Update(repo); err != nil {
return err
}
_, err = db.Exec(ctx, "UPDATE `issue` SET milestone_id = 0 WHERE milestone_id = ?", m.ID)
return err
})
}
func updateRepoMilestoneNum(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_milestones=(SELECT count(*) FROM milestone WHERE repo_id=?),num_closed_milestones=(SELECT count(*) FROM milestone WHERE repo_id=? AND is_closed=?) WHERE id=?",
repoID,
repoID,
true,
repoID,
)
return err
}
// LoadTotalTrackedTime loads the tracked time for the milestone
func (m *Milestone) LoadTotalTrackedTime(ctx context.Context) error {
type totalTimesByMilestone struct {
MilestoneID int64
Time int64
}
totalTime := &totalTimesByMilestone{MilestoneID: m.ID}
has, err := db.GetEngine(ctx).Table("issue").
Join("INNER", "milestone", "issue.milestone_id = milestone.id").
Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id").
Where("tracked_time.deleted = ?", false).
Select("milestone_id, sum(time) as time").
Where("milestone_id = ?", m.ID).
GroupBy("milestone_id").
Get(totalTime)
if err != nil {
return err
} else if !has {
return nil
}
m.TotalTrackedTime = totalTime.Time
return nil
}
// InsertMilestones creates milestones of repository.
func InsertMilestones(ctx context.Context, ms ...*Milestone) (err error) {
if len(ms) == 0 {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
// to return the id, so we should not use batch insert
for _, m := range ms {
if _, err = db.GetEngine(ctx).NoAutoTime().Insert(m); err != nil {
return err
}
}
_, err = db.Exec(ctx, "UPDATE `repository` SET num_milestones = num_milestones + ? WHERE id = ?", len(ms), ms[0].RepoID)
return err
})
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"strings"
"gitea.dev/models/db"
"gitea.dev/modules/optional"
"xorm.io/builder"
)
// MilestoneList is a list of milestones offering additional functionality
type MilestoneList []*Milestone
func (milestones MilestoneList) getMilestoneIDs() []int64 {
ids := make([]int64, 0, len(milestones))
for _, ms := range milestones {
ids = append(ids, ms.ID)
}
return ids
}
// SplitByOpenClosed splits the milestone list into open and closed milestones
func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList) {
for _, m := range milestones {
if m.IsClosed {
closed = append(closed, m)
} else {
open = append(open, m)
}
}
return open, closed
}
// FindMilestoneOptions contain options to get milestones
type FindMilestoneOptions struct {
db.ListOptions
RepoID int64
IsClosed optional.Option[bool]
Name string
SortType string
RepoCond builder.Cond
RepoIDs []int64
}
func (opts FindMilestoneOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID != 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.IsClosed.Has() {
cond = cond.And(builder.Eq{"is_closed": opts.IsClosed.Value()})
}
if opts.RepoCond != nil && opts.RepoCond.IsValid() {
cond = cond.And(builder.In("repo_id", builder.Select("id").From("repository").Where(opts.RepoCond)))
}
if len(opts.RepoIDs) > 0 {
cond = cond.And(builder.In("repo_id", opts.RepoIDs))
}
if len(opts.Name) != 0 {
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
}
return cond
}
func (opts FindMilestoneOptions) ToOrders() string {
switch opts.SortType {
case "furthestduedate":
return "deadline_unix DESC"
case "leastcomplete":
return "completeness ASC"
case "mostcomplete":
return "completeness DESC"
case "leastissues":
return "num_issues ASC"
case "mostissues":
return "num_issues DESC"
case "id":
return "id ASC"
case "name":
return "name DESC"
default:
return "deadline_unix ASC, name ASC"
}
}
// GetMilestoneIDsByNames returns a list of milestone ids by given names.
// It doesn't filter them by repo, so it could return milestones belonging to different repos.
// It's used for filtering issues via indexer, otherwise it would be useless.
// Since it could return milestones with the same name, so the length of returned ids could be more than the length of names.
func GetMilestoneIDsByNames(ctx context.Context, names []string) ([]int64, error) {
var ids []int64
return ids, db.GetEngine(ctx).Table("milestone").
Where(db.BuildCaseInsensitiveIn("name", names)).
Cols("id").
Find(&ids)
}
// LoadTotalTrackedTimes loads for every milestone in the list the TotalTrackedTime by a batch request
func (milestones MilestoneList) LoadTotalTrackedTimes(ctx context.Context) error {
type totalTimesByMilestone struct {
MilestoneID int64
Time int64
}
if len(milestones) == 0 {
return nil
}
trackedTimes := make(map[int64]int64, len(milestones))
// Get total tracked time by milestone_id
rows, err := db.GetEngine(ctx).Table("issue").
Join("INNER", "milestone", "issue.milestone_id = milestone.id").
Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id").
Where("tracked_time.deleted = ?", false).
Select("milestone_id, sum(time) as time").
In("milestone_id", milestones.getMilestoneIDs()).
GroupBy("milestone_id").
Rows(new(totalTimesByMilestone))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var totalTime totalTimesByMilestone
err = rows.Scan(&totalTime)
if err != nil {
return err
}
trackedTimes[totalTime.MilestoneID] = totalTime.Time
}
for _, milestone := range milestones {
milestone.TotalTrackedTime = trackedTimes[milestone.ID]
}
return nil
}
// CountMilestonesByRepoCondAndKw map from repo conditions and the keyword of milestones' name to number of milestones matching the options`
func CountMilestonesMap(ctx context.Context, opts FindMilestoneOptions) (map[int64]int64, error) {
sess := db.GetEngine(ctx).Where(opts.ToConds())
countsSlice := make([]*struct {
RepoID int64
Count int64
}, 0, 10)
if err := sess.GroupBy("repo_id").
Select("repo_id AS repo_id, COUNT(*) AS count").
Table("milestone").
Find(&countsSlice); err != nil {
return nil, err
}
countMap := make(map[int64]int64, len(countsSlice))
for _, c := range countsSlice {
countMap[c.RepoID] = c.Count
}
return countMap, nil
}
// MilestonesStats represents milestone statistic information.
type MilestonesStats struct {
OpenCount, ClosedCount int64
}
// Total returns the total counts of milestones
func (m MilestonesStats) Total() int64 {
return m.OpenCount + m.ClosedCount
}
// GetMilestonesStatsByRepoCondAndKw returns milestone statistic information for dashboard by given repo conditions and name keyword.
func GetMilestonesStatsByRepoCondAndKw(ctx context.Context, repoCond builder.Cond, keyword string) (*MilestonesStats, error) {
var err error
stats := &MilestonesStats{}
sess := db.GetEngine(ctx).Where("is_closed = ?", false)
if len(keyword) > 0 {
sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
}
if repoCond.IsValid() {
sess.And(builder.In("repo_id", builder.Select("id").From("repository").Where(repoCond)))
}
stats.OpenCount, err = sess.Count(new(Milestone))
if err != nil {
return nil, err
}
sess = db.GetEngine(ctx).Where("is_closed = ?", true)
if len(keyword) > 0 {
sess = sess.And(builder.Like{"UPPER(name)", strings.ToUpper(keyword)})
}
if repoCond.IsValid() {
sess.And(builder.In("repo_id", builder.Select("id").From("repository").Where(repoCond)))
}
stats.ClosedCount, err = sess.Count(new(Milestone))
if err != nil {
return nil, err
}
return stats, nil
}
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"sort"
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestMilestone_State(t *testing.T) {
assert.Equal(t, api.StateOpen, (&issues_model.Milestone{IsClosed: false}).State())
assert.Equal(t, api.StateClosed, (&issues_model.Milestone{IsClosed: true}).State())
}
func TestGetMilestoneByRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestone, err := issues_model.GetMilestoneByRepoID(t.Context(), 1, 1)
assert.NoError(t, err)
assert.EqualValues(t, 1, milestone.ID)
assert.EqualValues(t, 1, milestone.RepoID)
_, err = issues_model.GetMilestoneByRepoID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
assert.True(t, issues_model.IsErrMilestoneNotExist(err))
}
func TestGetMilestonesByRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(repoID int64, state api.StateType) {
var isClosed optional.Option[bool]
switch state {
case api.StateClosed, api.StateOpen:
isClosed = optional.Some(state == api.StateClosed)
}
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
milestones, err := db.Find[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: repo.ID,
IsClosed: isClosed,
})
assert.NoError(t, err)
var n int
switch state {
case api.StateClosed:
n = repo.NumClosedMilestones
case api.StateAll:
n = repo.NumMilestones
case api.StateOpen:
fallthrough
default:
n = repo.NumOpenMilestones
}
assert.Len(t, milestones, n)
for _, milestone := range milestones {
assert.Equal(t, repoID, milestone.RepoID)
}
}
test(1, api.StateOpen)
test(1, api.StateAll)
test(1, api.StateClosed)
test(2, api.StateOpen)
test(2, api.StateAll)
test(2, api.StateClosed)
test(3, api.StateOpen)
test(3, api.StateClosed)
test(3, api.StateAll)
milestones, err := db.Find[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: unittest.NonexistentID,
IsClosed: optional.Some(false),
})
assert.NoError(t, err)
assert.Empty(t, milestones)
}
func TestGetMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
test := func(sortType string, sortCond func(*issues_model.Milestone) int) {
for _, page := range []int{0, 1} {
milestones, err := db.Find[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
},
RepoID: repo.ID,
IsClosed: optional.Some(false),
SortType: sortType,
})
assert.NoError(t, err)
assert.Len(t, milestones, repo.NumMilestones-repo.NumClosedMilestones)
values := make([]int, len(milestones))
for i, milestone := range milestones {
values[i] = sortCond(milestone)
}
assert.True(t, sort.IntsAreSorted(values))
milestones, err = db.Find[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
},
RepoID: repo.ID,
IsClosed: optional.Some(true),
Name: "",
SortType: sortType,
})
assert.NoError(t, err)
assert.Len(t, milestones, repo.NumClosedMilestones)
values = make([]int, len(milestones))
for i, milestone := range milestones {
values[i] = sortCond(milestone)
}
assert.True(t, sort.IntsAreSorted(values))
}
}
test("furthestduedate", func(milestone *issues_model.Milestone) int {
return -int(milestone.DeadlineUnix)
})
test("leastcomplete", func(milestone *issues_model.Milestone) int {
return milestone.Completeness
})
test("mostcomplete", func(milestone *issues_model.Milestone) int {
return -milestone.Completeness
})
test("leastissues", func(milestone *issues_model.Milestone) int {
return milestone.NumIssues
})
test("mostissues", func(milestone *issues_model.Milestone) int {
return -milestone.NumIssues
})
test("soonestduedate", func(milestone *issues_model.Milestone) int {
return int(milestone.DeadlineUnix)
})
}
func TestCountRepoMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(repoID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
count, err := db.Count[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: repoID,
})
assert.NoError(t, err)
assert.EqualValues(t, repo.NumMilestones, count)
}
test(1)
test(2)
test(3)
count, err := db.Count[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: unittest.NonexistentID,
})
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
}
func TestCountRepoClosedMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(repoID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
count, err := db.Count[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: repoID,
IsClosed: optional.Some(true),
})
assert.NoError(t, err)
assert.EqualValues(t, repo.NumClosedMilestones, count)
}
test(1)
test(2)
test(3)
count, err := db.Count[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
RepoID: unittest.NonexistentID,
IsClosed: optional.Some(true),
})
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
}
func TestCountMilestonesByRepoIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestonesCount := func(repoID int64) (int, int) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
return repo.NumOpenMilestones, repo.NumClosedMilestones
}
repo1OpenCount, repo1ClosedCount := milestonesCount(1)
repo2OpenCount, repo2ClosedCount := milestonesCount(2)
openCounts, err := issues_model.CountMilestonesMap(t.Context(), issues_model.FindMilestoneOptions{
RepoIDs: []int64{1, 2},
IsClosed: optional.Some(false),
})
assert.NoError(t, err)
assert.EqualValues(t, repo1OpenCount, openCounts[1])
assert.EqualValues(t, repo2OpenCount, openCounts[2])
closedCounts, err := issues_model.CountMilestonesMap(t.Context(),
issues_model.FindMilestoneOptions{
RepoIDs: []int64{1, 2},
IsClosed: optional.Some(true),
})
assert.NoError(t, err)
assert.EqualValues(t, repo1ClosedCount, closedCounts[1])
assert.EqualValues(t, repo2ClosedCount, closedCounts[2])
}
func TestGetMilestonesByRepoIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
test := func(sortType string, sortCond func(*issues_model.Milestone) int) {
for _, page := range []int{0, 1} {
openMilestones, err := db.Find[issues_model.Milestone](t.Context(), issues_model.FindMilestoneOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
},
RepoIDs: []int64{repo1.ID, repo2.ID},
IsClosed: optional.Some(false),
SortType: sortType,
})
assert.NoError(t, err)
assert.Len(t, openMilestones, repo1.NumOpenMilestones+repo2.NumOpenMilestones)
values := make([]int, len(openMilestones))
for i, milestone := range openMilestones {
values[i] = sortCond(milestone)
}
assert.True(t, sort.IntsAreSorted(values))
closedMilestones, err := db.Find[issues_model.Milestone](t.Context(),
issues_model.FindMilestoneOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
},
RepoIDs: []int64{repo1.ID, repo2.ID},
IsClosed: optional.Some(true),
SortType: sortType,
})
assert.NoError(t, err)
assert.Len(t, closedMilestones, repo1.NumClosedMilestones+repo2.NumClosedMilestones)
values = make([]int, len(closedMilestones))
for i, milestone := range closedMilestones {
values[i] = sortCond(milestone)
}
assert.True(t, sort.IntsAreSorted(values))
}
}
test("furthestduedate", func(milestone *issues_model.Milestone) int {
return -int(milestone.DeadlineUnix)
})
test("leastcomplete", func(milestone *issues_model.Milestone) int {
return milestone.Completeness
})
test("mostcomplete", func(milestone *issues_model.Milestone) int {
return -milestone.Completeness
})
test("leastissues", func(milestone *issues_model.Milestone) int {
return milestone.NumIssues
})
test("mostissues", func(milestone *issues_model.Milestone) int {
return -milestone.NumIssues
})
test("soonestduedate", func(milestone *issues_model.Milestone) int {
return int(milestone.DeadlineUnix)
})
}
func TestNewMilestone(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestone := &issues_model.Milestone{
RepoID: 1,
Name: "milestoneName",
Content: "milestoneContent",
}
assert.NoError(t, issues_model.NewMilestone(t.Context(), milestone))
unittest.AssertExistsAndLoadBean(t, milestone)
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: milestone.RepoID}, &issues_model.Milestone{})
}
func TestChangeMilestoneStatus(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
assert.NoError(t, issues_model.ChangeMilestoneStatus(t.Context(), milestone, true))
unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}, "is_closed=1")
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: milestone.RepoID}, &issues_model.Milestone{})
assert.NoError(t, issues_model.ChangeMilestoneStatus(t.Context(), milestone, false))
unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}, "is_closed=0")
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: milestone.RepoID}, &issues_model.Milestone{})
}
func TestDeleteMilestoneByRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.NoError(t, issues_model.DeleteMilestoneByRepoID(t.Context(), 1, 1))
unittest.AssertNotExistsBean(t, &issues_model.Milestone{ID: 1})
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: 1})
assert.NoError(t, issues_model.DeleteMilestoneByRepoID(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
}
func TestUpdateMilestone(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
milestone.Name = " newMilestoneName "
milestone.Content = "newMilestoneContent"
assert.NoError(t, issues_model.UpdateMilestone(t.Context(), milestone, milestone.IsClosed))
milestone = unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
assert.Equal(t, "newMilestoneName", milestone.Name)
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
}
func TestUpdateMilestoneCounters(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{MilestoneID: 1},
"is_closed=0")
issue.IsClosed = true
issue.ClosedUnix = timeutil.TimeStampNow()
_, err := db.GetEngine(t.Context()).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
assert.NoError(t, err)
assert.NoError(t, issues_model.UpdateMilestoneCounters(t.Context(), issue.MilestoneID))
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
issue.IsClosed = false
issue.ClosedUnix = 0
_, err = db.GetEngine(t.Context()).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
assert.NoError(t, err)
assert.NoError(t, issues_model.UpdateMilestoneCounters(t.Context(), issue.MilestoneID))
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
}
func TestMigrate_InsertMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
reponame := "repo1"
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
name := "milestonetest1"
ms := &issues_model.Milestone{
RepoID: repo.ID,
Name: name,
}
err := issues_model.InsertMilestones(t.Context(), ms)
assert.NoError(t, err)
unittest.AssertExistsAndLoadBean(t, ms)
repoModified := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Equal(t, repo.NumMilestones+1, repoModified.NumMilestones)
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
}
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"gitea.dev/models/db"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// PullRequestsOptions holds the options for PRs
type PullRequestsOptions struct {
db.ListOptions
State string
SortType string
Labels []int64
MilestoneID int64
PosterID int64
BaseBranch string
}
func listPullRequestStatement(ctx context.Context, baseRepoID int64, opts *PullRequestsOptions) db.Session {
sess := db.GetEngine(ctx).Where("pull_request.base_repo_id=?", baseRepoID)
if opts.BaseBranch != "" {
sess.And("pull_request.base_branch=?", opts.BaseBranch)
}
sess.Join("INNER", "issue", "pull_request.issue_id = issue.id")
switch opts.State {
case "closed", "open":
sess.And("issue.is_closed=?", opts.State == "closed")
}
if len(opts.Labels) > 0 {
sess.Join("INNER", "issue_label", "issue.id = issue_label.issue_id").
In("issue_label.label_id", opts.Labels)
}
if opts.MilestoneID > 0 {
sess.And("issue.milestone_id=?", opts.MilestoneID)
}
if opts.PosterID > 0 {
sess.And("issue.poster_id=?", opts.PosterID)
}
return sess
}
// GetUnmergedPullRequestsByHeadInfo returns all pull requests that are open and has not been merged
func GetUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) {
prs := make([]*PullRequest, 0, 2)
sess := db.GetEngine(ctx).
Join("INNER", "issue", "issue.id = pull_request.issue_id").
Where("head_repo_id = ? AND head_branch = ? AND has_merged = ? AND issue.is_closed = ? AND flow = ?", repoID, branch, false, false, PullRequestFlowGithub)
return prs, sess.Find(&prs)
}
// CanMaintainerWriteToBranch check whether user is a maintainer and could write to the branch
func CanMaintainerWriteToBranch(ctx context.Context, headPerm access_model.Permission, headBranch string, doer *user_model.User) bool {
can, err := canMaintainerWriteToBranch(ctx, headPerm, headBranch, doer)
if err != nil {
log.Error("CanMaintainerWriteToBranch: %v", err)
return false
}
return can
}
func canMaintainerWriteToBranch(ctx context.Context, headPerm access_model.Permission, headBranch string, doer *user_model.User) (bool, error) {
if headPerm.CanWrite(unit.TypeCode) {
return true, nil
}
// the code below depends on units to get the repository ID, not ideal but just keep it for now
firstUnitRepoID := headPerm.GetFirstUnitRepoID()
if firstUnitRepoID == 0 {
return false, nil
}
prs, err := GetUnmergedPullRequestsByHeadInfo(ctx, firstUnitRepoID, headBranch)
if err != nil {
return false, err
}
if _, err := prs.LoadIssues(ctx); err != nil {
return false, err
}
for _, pr := range prs {
if !pr.AllowMaintainerEdit {
continue
}
// check the PR's poster's permissions
// If a "reader" poster created the PR in base repo from head repo, even if it is allowed to be edited by maintainers,
// the maintainers should not be allowed to write, because they don't really have "write" permission in the head repo
if err := pr.Issue.LoadPoster(ctx); err != nil {
return false, err
}
if err := pr.LoadHeadRepo(ctx); err != nil {
return false, err
}
posterHeadPerm, err := access_model.GetIndividualUserRepoPermission(ctx, pr.HeadRepo, pr.Issue.Poster)
if err != nil {
return false, err
}
if !posterHeadPerm.CanWrite(unit.TypeCode) {
continue
}
// check the doer's permission
// Only allow the doer to edit the PR if they have write access to the base repository
if err := pr.LoadBaseRepo(ctx); err != nil {
return false, err
}
doerBasePerm, err := access_model.GetIndividualUserRepoPermission(ctx, pr.BaseRepo, doer)
if err != nil {
return false, err
}
if doerBasePerm.CanWrite(unit.TypeCode) {
return true, nil
}
}
return false, nil
}
// HasUnmergedPullRequestsByHeadInfo checks if there are open and not merged pull request
// by given head information (repo and branch)
func HasUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) (bool, error) {
return db.GetEngine(ctx).
Where("head_repo_id = ? AND head_branch = ? AND has_merged = ? AND issue.is_closed = ? AND flow = ?",
repoID, branch, false, false, PullRequestFlowGithub).
Join("INNER", "issue", "issue.id = pull_request.issue_id").
Exist(&PullRequest{})
}
// GetUnmergedPullRequestsByBaseInfo returns all pull requests that are open and has not been merged
// by given base information (repo and branch).
func GetUnmergedPullRequestsByBaseInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) {
prs := make([]*PullRequest, 0, 2)
return prs, db.GetEngine(ctx).
Where("base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?",
repoID, branch, false, false).
OrderBy("issue.updated_unix DESC").
Join("INNER", "issue", "issue.id=pull_request.issue_id").
Find(&prs)
}
// GetPullRequestIDsByCheckStatus returns all pull requests according the special checking status.
func GetPullRequestIDsByCheckStatus(ctx context.Context, status PullRequestStatus) ([]int64, error) {
prs := make([]int64, 0, 10)
return prs, db.GetEngine(ctx).Table("pull_request").
Where("status=?", status).
Cols("pull_request.id").
Find(&prs)
}
// PullRequests returns all pull requests for a base Repo by the given conditions
func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptions) (PullRequestList, int64, error) {
if opts.Page <= 0 {
opts.Page = 1
}
countSession := listPullRequestStatement(ctx, baseRepoID, opts)
maxResults, err := countSession.Count(new(PullRequest))
if err != nil {
log.Error("Count PRs: %v", err)
return nil, maxResults, err
}
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
applySorts(findSession, opts.SortType, 0)
findSession = db.SetSessionPagination(findSession, opts)
prs := make([]*PullRequest, 0, opts.PageSize)
found := findSession.Find(&prs)
return prs, maxResults, found
}
// PullRequestList defines a list of pull requests
type PullRequestList []*PullRequest
func (prs PullRequestList) getRepositoryIDs() []int64 {
repoIDs := make(container.Set[int64])
for _, pr := range prs {
if pr.BaseRepo == nil && pr.BaseRepoID > 0 {
repoIDs.Add(pr.BaseRepoID)
}
if pr.HeadRepo == nil && pr.HeadRepoID > 0 {
repoIDs.Add(pr.HeadRepoID)
}
}
return repoIDs.Values()
}
func (prs PullRequestList) SetBaseRepo(baseRepo *repo_model.Repository) {
for _, pr := range prs {
if pr.BaseRepo == nil {
pr.BaseRepo = baseRepo
}
}
}
func (prs PullRequestList) SetHeadRepo(headRepo *repo_model.Repository) {
for _, pr := range prs {
if pr.HeadRepo == nil {
pr.HeadRepo = headRepo
pr.isHeadRepoLoaded = true
}
}
}
func (prs PullRequestList) LoadRepositories(ctx context.Context) error {
repoIDs := prs.getRepositoryIDs()
reposMap := make(map[int64]*repo_model.Repository, len(repoIDs))
if err := db.GetEngine(ctx).
In("id", repoIDs).
Find(&reposMap); err != nil {
return fmt.Errorf("find repos: %w", err)
}
for _, pr := range prs {
if pr.BaseRepo == nil {
pr.BaseRepo = reposMap[pr.BaseRepoID]
}
if pr.HeadRepo == nil {
pr.HeadRepo = reposMap[pr.HeadRepoID]
pr.isHeadRepoLoaded = true
}
}
return nil
}
func (prs PullRequestList) LoadAttributes(ctx context.Context) error {
if _, err := prs.LoadIssues(ctx); err != nil {
return err
}
return nil
}
func (prs PullRequestList) LoadIssues(ctx context.Context) (IssueList, error) {
if len(prs) == 0 {
return nil, nil
}
// Load issues which are not loaded
issueIDs := container.FilterSlice(prs, func(pr *PullRequest) (int64, bool) {
return pr.IssueID, pr.Issue == nil && pr.IssueID > 0
})
issues := make(map[int64]*Issue, len(issueIDs))
if err := db.GetEngine(ctx).
In("id", issueIDs).
Find(&issues); err != nil {
return nil, fmt.Errorf("find issues: %w", err)
}
issueList := make(IssueList, 0, len(prs))
for _, pr := range prs {
if pr.Issue == nil {
pr.Issue = issues[pr.IssueID]
/*
Old code:
pr.Issue.PullRequest = pr // panic here means issueIDs and prs are not in sync
It's worth panic because it's almost impossible to happen under normal use.
But in integration testing, an asynchronous task could read a database that has been reset.
So returning an error would make more sense, let the caller has a choice to ignore it.
*/
if pr.Issue == nil {
return nil, fmt.Errorf("issues and prs may be not in sync: cannot find issue %v for pr %v: %w", pr.IssueID, pr.ID, util.ErrNotExist)
}
}
pr.Issue.PullRequest = pr
if pr.Issue.Repo == nil {
pr.Issue.Repo = pr.BaseRepo
}
issueList = append(issueList, pr.Issue)
}
return issueList, nil
}
// GetIssueIDs returns all issue ids
func (prs PullRequestList) GetIssueIDs() []int64 {
return container.FilterSlice(prs, func(pr *PullRequest) (int64, bool) {
return pr.IssueID, pr.IssueID > 0
})
}
func (prs PullRequestList) LoadReviewCommentsCounts(ctx context.Context) (map[int64]int, error) {
issueIDs := prs.GetIssueIDs()
countsMap := make(map[int64]int, len(issueIDs))
counts := make([]struct {
IssueID int64
Count int
}, 0, len(issueIDs))
if err := db.GetEngine(ctx).Select("issue_id, count(*) as count").
Table("comment").In("issue_id", issueIDs).And("type = ?", CommentTypeReview).
GroupBy("issue_id").Find(&counts); err != nil {
return nil, err
}
for _, c := range counts {
countsMap[c.IssueID] = c.Count
}
return countsMap, nil
}
func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error) {
issueIDs := prs.GetIssueIDs()
reviews := make([]*Review, 0, len(issueIDs))
subQuery := builder.Select("max(id) as id").
From("review").
Where(builder.In("issue_id", issueIDs)).
And(builder.In("`type`", ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest)).
And(builder.Eq{
"dismissed": false,
"original_author_id": 0,
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_id")
// Get latest review of each reviewer, sorted in order they were made
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
return nil, err
}
teamReviewRequests := make([]*Review, 0, 5)
subQueryTeam := builder.Select("max(id) as id").
From("review").
Where(builder.In("issue_id", issueIDs)).
And(builder.Eq{
"original_author_id": 0,
}).And(builder.Neq{
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_team_id")
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil {
return nil, err
}
if len(teamReviewRequests) > 0 {
reviews = append(reviews, teamReviewRequests...)
}
return reviews, nil
}
// HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo
func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) {
return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0)
}
// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit)
func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID int64, beforeUnix timeutil.TimeStamp, excludePullID int64) (bool, error) {
sess := db.GetEngine(ctx).
Join("INNER", "pull_request", "pull_request.issue_id = issue.id").
Where("repo_id=?", repoID).
And("poster_id=?", posterID).
And("is_pull=?", true).
And("pull_request.has_merged=?", true)
if beforeUnix > 0 {
sess.And("pull_request.merged_unix < ?", beforeUnix)
}
if excludePullID > 0 {
sess.And("pull_request.id != ?", excludePullID)
}
return sess.
Select("issue.id").
Limit(1).
Get(new(Issue))
}
// GetPullRequestByIssueIDs returns all pull requests by issue ids
func GetPullRequestByIssueIDs(ctx context.Context, issueIDs []int64) (PullRequestList, error) {
prs := make([]*PullRequest, 0, len(issueIDs))
return prs, db.GetEngine(ctx).
Where("issue_id > 0").
In("issue_id", issueIDs).
Find(&prs)
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/builder"
)
func TestPullRequestList(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("LoadAttributes", testPullRequestListLoadAttributes)
t.Run("LoadReviewCommentsCounts", testPullRequestListLoadReviewCommentsCounts)
t.Run("LoadReviews", testPullRequestListLoadReviews)
t.Run("CanMaintainerWriteToBranch", testCanMaintainerWriteToBranch)
}
func testPullRequestListLoadAttributes(t *testing.T) {
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
assert.NoError(t, prs.LoadAttributes(t.Context()))
for _, pr := range prs {
assert.NotNil(t, pr.Issue)
assert.Equal(t, pr.IssueID, pr.Issue.ID)
}
assert.NoError(t, issues_model.PullRequestList([]*issues_model.PullRequest{}).LoadAttributes(t.Context()))
}
func testPullRequestListLoadReviewCommentsCounts(t *testing.T) {
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
reviewComments, err := prs.LoadReviewCommentsCounts(t.Context())
assert.NoError(t, err)
assert.Len(t, reviewComments, 2)
for _, pr := range prs {
assert.Equal(t, 1, reviewComments[pr.IssueID])
}
}
func testPullRequestListLoadReviews(t *testing.T) {
prs := issues_model.PullRequestList{
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}),
unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}),
}
reviewList, err := prs.LoadReviews(t.Context())
assert.NoError(t, err)
// 1, 7, 8, 9, 10, 22
assert.Len(t, reviewList, 6)
assert.EqualValues(t, 1, reviewList[0].ID)
assert.EqualValues(t, 7, reviewList[1].ID)
assert.EqualValues(t, 8, reviewList[2].ID)
assert.EqualValues(t, 9, reviewList[3].ID)
assert.EqualValues(t, 10, reviewList[4].ID)
assert.EqualValues(t, 22, reviewList[5].ID)
}
func testCanMaintainerWriteToBranch(t *testing.T) {
ctx := t.Context()
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
_ = baseRepo.LoadOwner(ctx)
_ = headRepo.LoadOwner(ctx)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// a PR from header's owner
headOwnerPR := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: headRepo.OwnerID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "pr-from-head-owner",
BaseBranch: "master",
}
require.NoError(t, issues_model.NewPullRequest(ctx, baseRepo, headOwnerPR.Issue, nil, nil, headOwnerPR))
// a PR from a user, they might have or not have "write" permission in the target repo
anyUserPR := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: user.ID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "pr-from-head-user",
BaseBranch: "master",
}
require.NoError(t, issues_model.NewPullRequest(ctx, baseRepo, anyUserPR.Issue, nil, nil, anyUserPR))
doerCanWrite := func(doer *user_model.User, pr *issues_model.PullRequest) bool {
headPerm, _ := access.GetIndividualUserRepoPermission(ctx, headRepo, doer)
return issues_model.CanMaintainerWriteToBranch(ctx, headPerm, pr.HeadBranch, doer)
}
t.Run("NoAllowMaintainerEdit", func(t *testing.T) {
assert.True(t, doerCanWrite(headRepo.Owner, headOwnerPR))
assert.False(t, doerCanWrite(baseRepo.Owner, headOwnerPR))
assert.False(t, doerCanWrite(baseRepo.Owner, anyUserPR))
assert.False(t, doerCanWrite(user, anyUserPR))
})
t.Run("WithAllowMaintainerEdit-HeadPosterReader", func(t *testing.T) {
_, err := db.GetEngine(ctx).Where(builder.In("id", []int64{headOwnerPR.ID, anyUserPR.ID})).
Cols("allow_maintainer_edit").
Update(&issues_model.PullRequest{AllowMaintainerEdit: true})
require.NoError(t, err)
assert.True(t, doerCanWrite(baseRepo.Owner, headOwnerPR))
assert.False(t, doerCanWrite(baseRepo.Owner, anyUserPR)) // poster doesn't have write permission, so maintainer can't write either
})
t.Run("WithAllowMaintainerEdit-HeadPosterWriter", func(t *testing.T) {
_, err := db.GetEngine(ctx).Where(builder.In("id", []int64{headOwnerPR.ID, anyUserPR.ID})).
Cols("allow_maintainer_edit").
Update(&issues_model.PullRequest{AllowMaintainerEdit: true})
require.NoError(t, err)
err = db.Insert(ctx, &repo_model.Collaboration{RepoID: headRepo.ID, UserID: user.ID, Mode: perm.AccessModeWrite})
require.NoError(t, err)
err = db.Insert(ctx, &access.Access{RepoID: headRepo.ID, UserID: user.ID, Mode: perm.AccessModeWrite})
require.NoError(t, err)
assert.True(t, doerCanWrite(baseRepo.Owner, headOwnerPR))
assert.True(t, doerCanWrite(baseRepo.Owner, anyUserPR)) // now the poster has the write permission
})
}
+425
View File
@@ -0,0 +1,425 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPullRequest(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("LoadAttributes", testPullRequestLoadAttributes)
t.Run("LoadIssue", testPullRequestLoadIssue)
t.Run("LoadBaseRepo", testPullRequestLoadBaseRepo)
t.Run("LoadHeadRepo", testPullRequestLoadHeadRepo)
t.Run("PullRequestsNewest", testPullRequestsNewest)
t.Run("PullRequestsOldest", testPullRequestsOldest)
t.Run("GetUnmergedPullRequest", testGetUnmergedPullRequest)
t.Run("HasUnmergedPullRequestsByHeadInfo", testHasUnmergedPullRequestsByHeadInfo)
t.Run("GetUnmergedPullRequestsByHeadInfo", testGetUnmergedPullRequestsByHeadInfo)
t.Run("GetUnmergedPullRequestsByBaseInfo", testGetUnmergedPullRequestsByBaseInfo)
t.Run("GetPullRequestByIndex", testGetPullRequestByIndex)
t.Run("GetPullRequestByID", testGetPullRequestByID)
t.Run("GetPullRequestByIssueID", testGetPullRequestByIssueID)
t.Run("PullRequest_UpdateCols", testPullRequestUpdateCols)
t.Run("PullRequest_IsWorkInProgress", testPullRequestIsWorkInProgress)
t.Run("PullRequest_GetWorkInProgressPrefixWorkInProgress", testPullRequestGetWorkInProgressPrefixWorkInProgress)
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
t.Run("GetApprovers", testGetApprovers)
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
t.Run("PullRequestsClosedRecentSortType", testPullRequestsClosedRecentSortType)
t.Run("LoadRequestedReviewers", testLoadRequestedReviewers)
}
func testPullRequestLoadAttributes(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadAttributes(t.Context()))
assert.NotNil(t, pr.Merger)
assert.Equal(t, pr.MergerID, pr.Merger.ID)
}
func testPullRequestLoadIssue(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NotNil(t, pr.Issue)
assert.Equal(t, int64(2), pr.Issue.ID)
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NotNil(t, pr.Issue)
assert.Equal(t, int64(2), pr.Issue.ID)
}
func testPullRequestLoadBaseRepo(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
assert.NotNil(t, pr.BaseRepo)
assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
assert.NotNil(t, pr.BaseRepo)
assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
}
func testPullRequestLoadHeadRepo(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pr.LoadHeadRepo(t.Context()))
assert.NotNil(t, pr.HeadRepo)
assert.Equal(t, pr.HeadRepoID, pr.HeadRepo.ID)
}
// TODO TestMerge
// TODO TestNewPullRequest
func testPullRequestsNewest(t *testing.T) {
prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "open",
SortType: "newest",
})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
if assert.Len(t, prs, 3) {
assert.EqualValues(t, 5, prs[0].ID)
assert.EqualValues(t, 2, prs[1].ID)
assert.EqualValues(t, 1, prs[2].ID)
}
}
func testPullRequestsClosedRecentSortType(t *testing.T) {
// Issue ID | Closed At. | Updated At
// 2 | 1707270001 | 1707270001
// 3 | 1707271000 | 1707279999
// 11 | 1707279999 | 1707275555
tests := []struct {
sortType string
expectedIssueIDOrder []int64
}{
{"recentupdate", []int64{3, 11, 2}},
{"recentclose", []int64{11, 3, 2}},
}
_, err := db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707270001, updated_unix = 1707270001, is_closed = true WHERE id = 2")
require.NoError(t, err)
_, err = db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707271000, updated_unix = 1707279999, is_closed = true WHERE id = 3")
require.NoError(t, err)
_, err = db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707279999, updated_unix = 1707275555, is_closed = true WHERE id = 11")
require.NoError(t, err)
for _, test := range tests {
t.Run(test.sortType, func(t *testing.T) {
prs, _, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "closed",
SortType: test.sortType,
})
require.NoError(t, err)
if assert.Len(t, prs, len(test.expectedIssueIDOrder)) {
for i := range test.expectedIssueIDOrder {
assert.Equal(t, test.expectedIssueIDOrder[i], prs[i].IssueID)
}
}
})
}
}
func testLoadRequestedReviewers(t *testing.T) {
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pull.LoadIssue(t.Context()))
issue := pull.Issue
assert.NoError(t, issue.LoadRepo(t.Context()))
assert.Empty(t, pull.RequestedReviewers)
user1, err := user_model.GetUserByID(t.Context(), 1)
assert.NoError(t, err)
comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}, false)
assert.NoError(t, err)
assert.NotNil(t, comment)
assert.NoError(t, pull.LoadRequestedReviewers(t.Context()))
assert.Len(t, pull.RequestedReviewers, 6)
comment, err = issues_model.RemoveReviewRequest(t.Context(), issue, user1, &user_model.User{})
assert.NoError(t, err)
assert.NotNil(t, comment)
pull.RequestedReviewers = nil
assert.NoError(t, pull.LoadRequestedReviewers(t.Context()))
assert.Empty(t, pull.RequestedReviewers)
}
func testPullRequestsOldest(t *testing.T) {
prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{
ListOptions: db.ListOptions{
Page: 1,
},
State: "open",
SortType: "oldest",
})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
if assert.Len(t, prs, 3) {
assert.EqualValues(t, 1, prs[0].ID)
assert.EqualValues(t, 2, prs[1].ID)
assert.EqualValues(t, 5, prs[2].ID)
}
}
func testGetUnmergedPullRequest(t *testing.T) {
pr, err := issues_model.GetUnmergedPullRequest(t.Context(), 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
assert.NoError(t, err)
assert.Equal(t, int64(2), pr.ID)
_, err = issues_model.GetUnmergedPullRequest(t.Context(), 1, 9223372036854775807, "branch1", "master", issues_model.PullRequestFlowGithub)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testHasUnmergedPullRequestsByHeadInfo(t *testing.T) {
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2")
assert.NoError(t, err)
assert.True(t, exist)
exist, err = issues_model.HasUnmergedPullRequestsByHeadInfo(t.Context(), 1, "not_exist_branch")
assert.NoError(t, err)
assert.False(t, exist)
}
func testGetUnmergedPullRequestsByHeadInfo(t *testing.T) {
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2")
assert.NoError(t, err)
assert.Len(t, prs, 1)
for _, pr := range prs {
assert.Equal(t, int64(1), pr.HeadRepoID)
assert.Equal(t, "branch2", pr.HeadBranch)
}
}
func testGetUnmergedPullRequestsByBaseInfo(t *testing.T) {
prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(t.Context(), 1, "master")
assert.NoError(t, err)
assert.Len(t, prs, 1)
pr := prs[0]
assert.Equal(t, int64(2), pr.ID)
assert.Equal(t, int64(1), pr.BaseRepoID)
assert.Equal(t, "master", pr.BaseBranch)
}
func testGetPullRequestByIndex(t *testing.T) {
pr, err := issues_model.GetPullRequestByIndex(t.Context(), 1, 2)
assert.NoError(t, err)
assert.Equal(t, int64(1), pr.BaseRepoID)
assert.Equal(t, int64(2), pr.Index)
_, err = issues_model.GetPullRequestByIndex(t.Context(), 9223372036854775807, 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
_, err = issues_model.GetPullRequestByIndex(t.Context(), 1, 0)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testGetPullRequestByID(t *testing.T) {
pr, err := issues_model.GetPullRequestByID(t.Context(), 1)
assert.NoError(t, err)
assert.Equal(t, int64(1), pr.ID)
assert.Equal(t, int64(2), pr.IssueID)
_, err = issues_model.GetPullRequestByID(t.Context(), 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testGetPullRequestByIssueID(t *testing.T) {
pr, err := issues_model.GetPullRequestByIssueID(t.Context(), 2)
assert.NoError(t, err)
assert.Equal(t, int64(2), pr.IssueID)
_, err = issues_model.GetPullRequestByIssueID(t.Context(), 9223372036854775807)
assert.Error(t, err)
assert.True(t, issues_model.IsErrPullRequestNotExist(err))
}
func testPullRequestUpdateCols(t *testing.T) {
pr := &issues_model.PullRequest{
ID: 1,
BaseBranch: "baseBranch",
HeadBranch: "headBranch",
}
assert.NoError(t, pr.UpdateCols(t.Context(), "head_branch"))
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.Equal(t, "master", pr.BaseBranch)
assert.Equal(t, "headBranch", pr.HeadBranch)
unittest.CheckConsistencyFor(t, pr)
}
// TODO TestAddTestPullRequestTask
func testPullRequestIsWorkInProgress(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
pr.LoadIssue(t.Context())
assert.False(t, pr.IsWorkInProgress(t.Context()))
pr.Issue.Title = "WIP: " + pr.Issue.Title
assert.True(t, pr.IsWorkInProgress(t.Context()))
pr.Issue.Title = "[wip]: " + pr.Issue.Title
assert.True(t, pr.IsWorkInProgress(t.Context()))
}
func testPullRequestGetWorkInProgressPrefixWorkInProgress(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
pr.LoadIssue(t.Context())
assert.Empty(t, pr.GetWorkInProgressPrefix(t.Context()))
original := pr.Issue.Title
pr.Issue.Title = "WIP: " + original
assert.Equal(t, "WIP:", pr.GetWorkInProgressPrefix(t.Context()))
pr.Issue.Title = "[wip] " + original
assert.Equal(t, "[wip]", pr.GetWorkInProgressPrefix(t.Context()))
}
func testDeleteOrphanedObjects(t *testing.T) {
countBefore, err := db.GetEngine(t.Context()).Count(&issues_model.PullRequest{})
assert.NoError(t, err)
_, err = db.GetEngine(t.Context()).Insert(&issues_model.PullRequest{IssueID: 1000}, &issues_model.PullRequest{IssueID: 1001}, &issues_model.PullRequest{IssueID: 1003})
assert.NoError(t, err)
orphaned, err := db.CountOrphanedObjects(t.Context(), "pull_request", "issue", "pull_request.issue_id=issue.id")
assert.NoError(t, err)
assert.EqualValues(t, 3, orphaned)
err = db.DeleteOrphanedObjects(t.Context(), "pull_request", "issue", "pull_request.issue_id=issue.id")
assert.NoError(t, err)
countAfter, err := db.GetEngine(t.Context()).Count(&issues_model.PullRequest{})
assert.NoError(t, err)
assert.Equal(t, countBefore, countAfter)
}
func testParseCodeOwnersLine(t *testing.T) {
type CodeOwnerTest struct {
Line string
Tokens []string
}
given := []CodeOwnerTest{
{Line: "", Tokens: nil},
{Line: "# comment", Tokens: []string{}},
{Line: "!.* @user1 @org1/team1", Tokens: []string{"!.*", "@user1", "@org1/team1"}},
{Line: `.*\\.js @user2 #comment`, Tokens: []string{`.*\.js`, "@user2"}},
{Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}},
{Line: `\#path @org3`, Tokens: []string{`#path`, "@org3"}},
{Line: `path\ with\ spaces/ @org3`, Tokens: []string{`path with spaces/`, "@org3"}},
{Line: `/docs/.*\\.md @user1`, Tokens: []string{`/docs/.*\.md`, "@user1"}},
{Line: `!/assets/.*\\.(bin|exe|msi) @user1`, Tokens: []string{`!/assets/.*\.(bin|exe|msi)`, "@user1"}},
}
for _, g := range given {
tokens := issues_model.TokenizeCodeOwnersLine(g.Line)
assert.Equal(t, g.Tokens, tokens, "Codeowners tokenizer failed")
}
}
func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
type testCase struct {
content string
file string
expected bool
}
cases := []testCase{
// Absolute path pattern should match (leading "/" stripped)
{content: "/README.md @user5\n", file: "README.md", expected: true},
// Absolute path pattern in subdirectory
{content: "/docs/.* @user5\n", file: "docs/foo.md", expected: true},
// Absolute path should not match nested paths it shouldn't
{content: "/docs/.* @user5\n", file: "other/docs/foo.md", expected: false},
// Relative path still works
{content: "README.md @user5\n", file: "README.md", expected: true},
// Negated absolute path pattern
{content: "!/.* @user5\n", file: "README.md", expected: false},
}
for _, c := range cases {
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), c.content)
require.NotEmpty(t, rules)
rule := rules[0]
regexpMatched, _ := rule.Rule.MatchString(c.file)
ruleMatched := regexpMatched == !rule.Negative
assert.Equal(t, c.expected, ruleMatched, "pattern %q against file %q", c.content, c.file)
}
}
func testGetApprovers(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
// Official reviews are already deduplicated. Allow unofficial reviews
// to assert that there are no duplicated approvers.
setting.Repository.PullRequest.DefaultMergeMessageOfficialApproversOnly = false
approvers := pr.GetApprovers(t.Context())
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
assert.Equal(t, expected, approvers)
}
func testGetPullRequestByMergedCommit(t *testing.T) {
pr, err := issues_model.GetPullRequestByMergedCommit(t.Context(), 1, "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3")
assert.NoError(t, err)
assert.EqualValues(t, 1, pr.ID)
_, err = issues_model.GetPullRequestByMergedCommit(t.Context(), 0, "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3")
assert.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
_, err = issues_model.GetPullRequestByMergedCommit(t.Context(), 1, "")
assert.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
}
func testMigrateInsertPullRequests(t *testing.T) {
reponame := "repo1"
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
i := &issues_model.Issue{
RepoID: repo.ID,
Repo: repo,
Title: "title1",
Content: "issuecontent1",
IsPull: true,
PosterID: owner.ID,
Poster: owner,
}
p := &issues_model.PullRequest{
Issue: i,
}
err := issues_model.InsertPullRequests(t.Context(), p)
assert.NoError(t, err)
_ = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{IssueID: i.ID})
unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.PullRequest{})
}
+367
View File
@@ -0,0 +1,367 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
"context"
"fmt"
"strings"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ErrForbiddenIssueReaction is used when a forbidden reaction was try to created
type ErrForbiddenIssueReaction struct {
Reaction string
}
// IsErrForbiddenIssueReaction checks if an error is a ErrForbiddenIssueReaction.
func IsErrForbiddenIssueReaction(err error) bool {
_, ok := err.(ErrForbiddenIssueReaction)
return ok
}
func (err ErrForbiddenIssueReaction) Error() string {
return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction)
}
func (err ErrForbiddenIssueReaction) Unwrap() error {
return util.ErrPermissionDenied
}
// ErrReactionAlreadyExist is used when a existing reaction was try to created
type ErrReactionAlreadyExist struct {
Reaction string
}
// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist.
func IsErrReactionAlreadyExist(err error) bool {
_, ok := err.(ErrReactionAlreadyExist)
return ok
}
func (err ErrReactionAlreadyExist) Error() string {
return fmt.Sprintf("reaction '%s' already exists", err.Reaction)
}
func (err ErrReactionAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}
// Reaction represents a reactions on issues and comments.
type Reaction struct {
ID int64 `xorm:"pk autoincr"`
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"`
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
CommentID int64 `xorm:"INDEX UNIQUE(s)"`
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"`
OriginalAuthor string `xorm:"INDEX UNIQUE(s)"`
User *user_model.User `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
// LoadUser load user of reaction
func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) {
if r.User != nil {
return r.User, nil
}
user, err := user_model.GetUserByID(ctx, r.UserID)
if err != nil {
return nil, err
}
r.User = user
return user, nil
}
// RemapExternalUser ExternalUserRemappable interface
func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error {
r.OriginalAuthor = externalName
r.OriginalAuthorID = externalID
r.UserID = userID
return nil
}
// GetUserID ExternalUserRemappable interface
func (r *Reaction) GetUserID() int64 { return r.UserID }
// GetExternalName ExternalUserRemappable interface
func (r *Reaction) GetExternalName() string { return r.OriginalAuthor }
// GetExternalID ExternalUserRemappable interface
func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID }
func init() {
db.RegisterModel(new(Reaction))
}
// FindReactionsOptions describes the conditions to Find reactions
type FindReactionsOptions struct {
db.ListOptions
IssueID int64
CommentID int64
UserID int64
Reaction string
}
func (opts *FindReactionsOptions) toConds() builder.Cond {
// If Issue ID is set add to Query
cond := builder.NewCond()
if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
}
// If CommentID is > 0 add to Query
// If it is 0 Query ignore CommentID to select
// If it is -1 it explicit search of Issue Reactions where CommentID = 0
if opts.CommentID > 0 {
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
} else if opts.CommentID == -1 {
cond = cond.And(builder.Eq{"reaction.comment_id": 0})
}
if opts.UserID > 0 {
cond = cond.And(builder.Eq{
"reaction.user_id": opts.UserID,
"reaction.original_author_id": 0,
})
}
if opts.Reaction != "" {
cond = cond.And(builder.Eq{"reaction.type": opts.Reaction})
}
return cond
}
// FindCommentReactions returns a ReactionList of all reactions from an comment
func FindCommentReactions(ctx context.Context, issueID, commentID int64) (ReactionList, int64, error) {
return FindReactions(ctx, FindReactionsOptions{
IssueID: issueID,
CommentID: commentID,
})
}
// FindIssueReactions returns a ReactionList of all reactions from an issue
func FindIssueReactions(ctx context.Context, issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) {
return FindReactions(ctx, FindReactionsOptions{
ListOptions: listOptions,
IssueID: issueID,
CommentID: -1,
})
}
// FindReactions returns a ReactionList of all reactions from an issue or a comment
func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) {
sess := db.GetEngine(ctx).
Where(opts.toConds()).
In("reaction.`type`", setting.UI.Reactions).
Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id")
if opts.Page > 0 {
db.SetSessionPagination(sess, &opts)
reactions := make([]*Reaction, 0, opts.PageSize)
count, err := sess.FindAndCount(&reactions)
return reactions, count, err
}
reactions := make([]*Reaction, 0, 10)
count, err := sess.FindAndCount(&reactions)
return reactions, count, err
}
func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
reaction := &Reaction{
Type: opts.Type,
UserID: opts.DoerID,
IssueID: opts.IssueID,
CommentID: opts.CommentID,
}
findOpts := FindReactionsOptions{
IssueID: opts.IssueID,
CommentID: opts.CommentID,
Reaction: opts.Type,
UserID: opts.DoerID,
}
if findOpts.CommentID == 0 {
// explicit search of Issue Reactions where CommentID = 0
findOpts.CommentID = -1
}
existingR, _, err := FindReactions(ctx, findOpts)
if err != nil {
return nil, err
}
if len(existingR) > 0 {
return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type}
}
if err := db.Insert(ctx, reaction); err != nil {
return nil, err
}
return reaction, nil
}
// ReactionOptions defines options for creating or deleting reactions
type ReactionOptions struct {
Type string
DoerID int64
IssueID int64
CommentID int64
}
// CreateReaction creates reaction for issue or comment.
func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
if !setting.UI.ReactionsLookup.Contains(opts.Type) {
return nil, ErrForbiddenIssueReaction{opts.Type}
}
return db.WithTx2(ctx, func(ctx context.Context) (*Reaction, error) {
return createReaction(ctx, opts)
})
}
// DeleteReaction deletes reaction for issue or comment.
func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
reaction := &Reaction{
Type: opts.Type,
UserID: opts.DoerID,
IssueID: opts.IssueID,
CommentID: opts.CommentID,
}
sess := db.GetEngine(ctx).Where("original_author_id = 0")
if opts.CommentID == -1 {
reaction.CommentID = 0
sess.MustCols("comment_id")
}
_, err := sess.Delete(reaction)
return err
}
// DeleteIssueReaction deletes a reaction on issue.
func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error {
return DeleteReaction(ctx, &ReactionOptions{
Type: content,
DoerID: doerID,
IssueID: issueID,
CommentID: -1,
})
}
// DeleteCommentReaction deletes a reaction on comment.
func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error {
return DeleteReaction(ctx, &ReactionOptions{
Type: content,
DoerID: doerID,
IssueID: issueID,
CommentID: commentID,
})
}
// ReactionList represents list of reactions
type ReactionList []*Reaction
// HasUser check if user has reacted
func (list ReactionList) HasUser(userID int64) bool {
if userID == 0 {
return false
}
for _, reaction := range list {
if reaction.OriginalAuthor == "" && reaction.UserID == userID {
return true
}
}
return false
}
// GroupByType returns reactions grouped by type
func (list ReactionList) GroupByType() map[string]ReactionList {
reactions := make(map[string]ReactionList)
for _, reaction := range list {
reactions[reaction.Type] = append(reactions[reaction.Type], reaction)
}
return reactions
}
func (list ReactionList) getUserIDs() []int64 {
return container.FilterSlice(list, func(reaction *Reaction) (int64, bool) {
if reaction.OriginalAuthor != "" {
return 0, false
}
return reaction.UserID, true
})
}
func valuesUser(m map[int64]*user_model.User) []*user_model.User {
values := make([]*user_model.User, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}
// newMigrationOriginalUser creates and returns a fake user for external user
func newMigrationOriginalUser(name string) *user_model.User {
return &user_model.User{ID: 0, Name: name, LowerName: strings.ToLower(name)}
}
// LoadUsers loads reactions' all users
func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) {
if len(list) == 0 {
return nil, nil
}
userIDs := list.getUserIDs()
userMaps := make(map[int64]*user_model.User, len(userIDs))
err := db.GetEngine(ctx).
In("id", userIDs).
Find(&userMaps)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
for _, reaction := range list {
if reaction.OriginalAuthor != "" {
reaction.User = newMigrationOriginalUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name()))
} else if user, ok := userMaps[reaction.UserID]; ok {
reaction.User = user
} else {
reaction.User = user_model.NewGhostUser()
}
}
return valuesUser(userMaps), nil
}
// GetFirstUsers returns first reacted user display names separated by comma
func (list ReactionList) GetFirstUsers() string {
var buffer bytes.Buffer
rem := setting.UI.ReactionMaxUserNum
for _, reaction := range list {
if buffer.Len() > 0 {
buffer.WriteString(", ")
}
buffer.WriteString(reaction.User.Name)
if rem--; rem == 0 {
break
}
}
return buffer.String()
}
// GetMoreUserCount returns count of not shown users in reaction tooltip
func (list ReactionList) GetMoreUserCount() int {
if len(list) <= setting.UI.ReactionMaxUserNum {
return 0
}
return len(list) - setting.UI.ReactionMaxUserNum
}
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"slices"
"sort"
"gitea.dev/models/db"
organization_model "gitea.dev/models/organization"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"xorm.io/builder"
)
type ReviewList []*Review
// LoadReviewers loads reviewers
func (reviews ReviewList) LoadReviewers(ctx context.Context) error {
reviewerIDs := make([]int64, len(reviews))
for i := range reviews {
reviewerIDs[i] = reviews[i].ReviewerID
}
reviewers, err := user_model.GetPossibleUserByIDs(ctx, reviewerIDs)
if err != nil {
return err
}
userMap := make(map[int64]*user_model.User, len(reviewers))
for _, reviewer := range reviewers {
userMap[reviewer.ID] = reviewer
}
for _, review := range reviews {
review.Reviewer = userMap[review.ReviewerID]
}
return nil
}
// LoadReviewersTeams loads reviewers teams
func (reviews ReviewList) LoadReviewersTeams(ctx context.Context) error {
reviewersTeamsIDs := make([]int64, 0)
for _, review := range reviews {
if review.ReviewerTeamID != 0 {
reviewersTeamsIDs = append(reviewersTeamsIDs, review.ReviewerTeamID)
}
}
teamsMap, err := organization_model.GetTeamsByIDs(ctx, reviewersTeamsIDs)
if err != nil {
return err
}
for _, review := range reviews {
if review.ReviewerTeamID != 0 {
review.ReviewerTeam = teamsMap[review.ReviewerTeamID]
}
}
return nil
}
func (reviews ReviewList) LoadIssues(ctx context.Context) error {
issueIDs := container.FilterSlice(reviews, func(review *Review) (int64, bool) {
return review.IssueID, true
})
issues, err := GetIssuesByIDs(ctx, issueIDs)
if err != nil {
return err
}
if _, err := issues.LoadRepositories(ctx); err != nil {
return err
}
issueMap := make(map[int64]*Issue, len(issues))
for _, issue := range issues {
issueMap[issue.ID] = issue
}
for _, review := range reviews {
review.Issue = issueMap[review.IssueID]
}
return nil
}
// FindReviewOptions represent possible filters to find reviews
type FindReviewOptions struct {
db.ListOptions
Types []ReviewType
IssueID int64
ReviewerID int64
OfficialOnly bool
Dismissed optional.Option[bool]
}
func (opts *FindReviewOptions) toCond() builder.Cond {
cond := builder.NewCond()
if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
}
if opts.ReviewerID > 0 {
cond = cond.And(builder.Eq{"reviewer_id": opts.ReviewerID})
}
if len(opts.Types) > 0 {
cond = cond.And(builder.In("type", opts.Types))
}
if opts.OfficialOnly {
cond = cond.And(builder.Eq{"official": true})
}
if opts.Dismissed.Has() {
cond = cond.And(builder.Eq{"dismissed": opts.Dismissed.Value()})
}
return cond
}
// FindReviews returns reviews passing FindReviewOptions
func FindReviews(ctx context.Context, opts FindReviewOptions) (ReviewList, error) {
reviews := make([]*Review, 0, 10)
sess := db.GetEngine(ctx).Where(opts.toCond())
if opts.Page > 0 && !opts.IsListAll() {
db.SetSessionPagination(sess, &opts)
}
return reviews, sess.
Asc("created_unix").
Asc("id").
Find(&reviews)
}
// FindLatestReviews returns only latest reviews per user, passing FindReviewOptions
func FindLatestReviews(ctx context.Context, opts FindReviewOptions) (ReviewList, error) {
reviews := make([]*Review, 0, 10)
cond := opts.toCond()
sess := db.GetEngine(ctx).Where(cond)
if opts.Page > 0 {
db.SetSessionPagination(sess, &opts)
}
sess.In("id", builder.
Select("max(id)").
From("review").
Where(cond).
GroupBy("reviewer_id"))
return reviews, sess.
Asc("created_unix").
Asc("id").
Find(&reviews)
}
// CountReviews returns count of reviews passing FindReviewOptions
func CountReviews(ctx context.Context, opts FindReviewOptions) (int64, error) {
return db.GetEngine(ctx).Where(opts.toCond()).Count(&Review{})
}
// GetReviewsByIssueID gets the latest review of each reviewer for a pull request
// The first returned parameter is the latest review of each individual reviewer or team
// The second returned parameter is the latest review of each original author which is migrated from other systems
// The reviews are sorted by updated time
func GetReviewsByIssueID(ctx context.Context, issueID int64) (latestReviews, migratedOriginalReviews ReviewList, err error) {
reviews := make([]*Review, 0, 10)
// Get all reviews for the issue id
if err := db.GetEngine(ctx).Where("issue_id=?", issueID).OrderBy("updated_unix ASC").Find(&reviews); err != nil {
return nil, nil, err
}
// filter them in memory to get the latest review of each reviewer
// Since the reviews should not be too many for one issue, less than 100 commonly, it's acceptable to do this in memory
// And since there are too less indexes in review table, it will be very slow to filter in the database
reviewersMap := make(map[int64][]*Review) // key is reviewer id
originalReviewersMap := make(map[int64][]*Review) // key is original author id
reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id
countedReviewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, ReviewTypeComment}
for _, review := range reviews {
if review.ReviewerTeamID == 0 && slices.Contains(countedReviewTypes, review.Type) && !review.Dismissed {
if review.OriginalAuthorID != 0 {
originalReviewersMap[review.OriginalAuthorID] = append(originalReviewersMap[review.OriginalAuthorID], review)
} else {
reviewersMap[review.ReviewerID] = append(reviewersMap[review.ReviewerID], review)
}
} else if review.ReviewerTeamID != 0 && review.OriginalAuthorID == 0 {
reviewTeamsMap[review.ReviewerTeamID] = append(reviewTeamsMap[review.ReviewerTeamID], review)
}
}
individualReviews := make([]*Review, 0, 10)
for _, reviews := range reviewersMap {
individualReviews = append(individualReviews, reviews[len(reviews)-1])
}
sort.Slice(individualReviews, func(i, j int) bool {
return individualReviews[i].UpdatedUnix < individualReviews[j].UpdatedUnix
})
originalReviews := make([]*Review, 0, 10)
for _, reviews := range originalReviewersMap {
originalReviews = append(originalReviews, reviews[len(reviews)-1])
}
sort.Slice(originalReviews, func(i, j int) bool {
return originalReviews[i].UpdatedUnix < originalReviews[j].UpdatedUnix
})
teamReviewRequests := make([]*Review, 0, 5)
for _, reviews := range reviewTeamsMap {
teamReviewRequests = append(teamReviewRequests, reviews[len(reviews)-1])
}
sort.Slice(teamReviewRequests, func(i, j int) bool {
return teamReviewRequests[i].UpdatedUnix < teamReviewRequests[j].UpdatedUnix
})
return append(individualReviews, teamReviewRequests...), originalReviews, nil
}
+388
View File
@@ -0,0 +1,388 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestGetReviewByID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
review, err := issues_model.GetReviewByID(t.Context(), 1)
assert.NoError(t, err)
assert.Equal(t, "Demo Review", review.Content)
assert.Equal(t, issues_model.ReviewTypeApprove, review.Type)
_, err = issues_model.GetReviewByID(t.Context(), 23892)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewNotExist(err), "IsErrReviewNotExist")
}
func TestReview_LoadAttributes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
assert.NoError(t, review.LoadAttributes(t.Context()))
assert.NotNil(t, review.Issue)
assert.NotNil(t, review.Reviewer)
invalidReview1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 2})
assert.Error(t, invalidReview1.LoadAttributes(t.Context()))
invalidReview2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 3})
assert.Error(t, invalidReview2.LoadAttributes(t.Context()))
}
func TestReview_LoadCodeComments(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 4})
assert.NoError(t, review.LoadAttributes(t.Context()))
assert.NoError(t, review.LoadCodeComments(t.Context()))
assert.Len(t, review.CodeComments, 1)
assert.Equal(t, int64(4), review.CodeComments["README.md"][int64(4)][0].Line)
}
func TestReviewType_Icon(t *testing.T) {
assert.Equal(t, "check", issues_model.ReviewTypeApprove.Icon())
assert.Equal(t, "diff", issues_model.ReviewTypeReject.Icon())
assert.Equal(t, "comment", issues_model.ReviewTypeComment.Icon())
assert.Equal(t, "comment", issues_model.ReviewTypeUnknown.Icon())
assert.Equal(t, "dot-fill", issues_model.ReviewTypeRequest.Icon())
assert.Equal(t, "comment", issues_model.ReviewType(6).Icon())
}
func TestFindReviews(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
reviews, err := issues_model.FindReviews(t.Context(), issues_model.FindReviewOptions{
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
IssueID: 2,
ReviewerID: 1,
})
assert.NoError(t, err)
assert.Len(t, reviews, 1)
assert.Equal(t, "Demo Review", reviews[0].Content)
}
func TestFindLatestReviews(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
reviews, err := issues_model.FindLatestReviews(t.Context(), issues_model.FindReviewOptions{
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
IssueID: 11,
})
assert.NoError(t, err)
assert.Len(t, reviews, 2)
assert.Equal(t, "duplicate review from user5 (latest)", reviews[0].Content)
assert.Equal(t, "singular review from org6 and final review for this pr", reviews[1].Content)
}
func TestGetCurrentReview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
review, err := issues_model.GetCurrentReview(t.Context(), user, issue)
assert.NoError(t, err)
assert.NotNil(t, review)
assert.Equal(t, issues_model.ReviewTypePending, review.Type)
assert.Equal(t, "Pending Review", review.Content)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 7})
review2, err := issues_model.GetCurrentReview(t.Context(), user2, issue)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewNotExist(err))
assert.Nil(t, review2)
}
func TestCreateReview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Content: "New Review",
Type: issues_model.ReviewTypePending,
Issue: issue,
Reviewer: user,
})
assert.NoError(t, err)
assert.Equal(t, "New Review", review.Content)
unittest.AssertExistsAndLoadBean(t, &issues_model.Review{Content: "New Review"})
}
func TestGetReviewersByIssueID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
expectedReviews := []*issues_model.Review{}
expectedReviews = append(expectedReviews,
&issues_model.Review{
ID: 5,
Reviewer: user1,
Type: issues_model.ReviewTypeComment,
UpdatedUnix: 946684810,
},
&issues_model.Review{
ID: 7,
Reviewer: org3,
Type: issues_model.ReviewTypeReject,
UpdatedUnix: 946684812,
},
&issues_model.Review{
ID: 8,
Reviewer: user4,
Type: issues_model.ReviewTypeApprove,
UpdatedUnix: 946684813,
},
&issues_model.Review{
ID: 9,
Reviewer: user2,
Type: issues_model.ReviewTypeReject,
UpdatedUnix: 946684814,
},
&issues_model.Review{
ID: 10,
Reviewer: user_model.NewGhostUser(),
Type: issues_model.ReviewTypeReject,
UpdatedUnix: 946684815,
},
&issues_model.Review{
ID: 22,
Reviewer: user5,
Type: issues_model.ReviewTypeRequest,
UpdatedUnix: 946684817,
},
)
allReviews, migratedReviews, err := issues_model.GetReviewsByIssueID(t.Context(), issue.ID)
assert.NoError(t, err)
assert.Empty(t, migratedReviews)
for _, review := range allReviews {
assert.NoError(t, review.LoadReviewer(t.Context()))
}
if assert.Len(t, allReviews, 6) {
for i, review := range allReviews {
assert.Equal(t, expectedReviews[i].ID, review.ID)
assert.Equal(t, expectedReviews[i].Reviewer, review.Reviewer)
assert.Equal(t, expectedReviews[i].Type, review.Type)
assert.Equal(t, expectedReviews[i].UpdatedUnix, review.UpdatedUnix)
}
}
}
func TestDismissReview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
rejectReviewExample := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
approveReviewExample := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 8})
assert.False(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), rejectReviewExample, true))
rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
assert.True(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), requestReviewExample, true))
rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
assert.True(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), requestReviewExample, true))
rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
assert.True(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), requestReviewExample, false))
rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
assert.True(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), requestReviewExample, false))
rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9})
requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11})
assert.True(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), rejectReviewExample, false))
assert.False(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.False(t, approveReviewExample.Dismissed)
assert.NoError(t, issues_model.DismissReview(t.Context(), approveReviewExample, true))
assert.False(t, rejectReviewExample.Dismissed)
assert.False(t, requestReviewExample.Dismissed)
assert.True(t, approveReviewExample.Dismissed)
}
func TestDeleteReview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
review1, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Content: "Official rejection",
Type: issues_model.ReviewTypeReject,
Official: false,
Issue: issue,
Reviewer: user,
})
assert.NoError(t, err)
review2, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Content: "Official approval",
Type: issues_model.ReviewTypeApprove,
Official: true,
Issue: issue,
Reviewer: user,
})
assert.NoError(t, err)
assert.NoError(t, issues_model.DeleteReview(t.Context(), review2))
_, err = issues_model.GetReviewByID(t.Context(), review2.ID)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewNotExist(err), "IsErrReviewNotExist")
review1, err = issues_model.GetReviewByID(t.Context(), review1.ID)
assert.NoError(t, err)
assert.True(t, review1.Official)
}
func TestDeleteDismissedReview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Content: "reject",
Type: issues_model.ReviewTypeReject,
Official: false,
Issue: issue,
Reviewer: user,
})
assert.NoError(t, err)
assert.NoError(t, issues_model.DismissReview(t.Context(), review, true))
comment, err := issues_model.CreateComment(t.Context(), &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeDismissReview,
Doer: user,
Repo: repo,
Issue: issue,
ReviewID: review.ID,
Content: "dismiss",
})
assert.NoError(t, err)
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
assert.NoError(t, issues_model.DeleteReview(t.Context(), review))
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
}
func TestSubmitReviewClearsStaleReviewRequest(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
assert.NoError(t, issue.LoadRepo(t.Context()))
assert.NoError(t, issue.Repo.LoadOwner(t.Context()))
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// the reviewer is requested to review the pull request
requestReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Type: issues_model.ReviewTypeRequest,
Issue: issue,
Reviewer: reviewer,
})
assert.NoError(t, err)
// the reviewer starts a pending review (e.g. by adding code comments)
pendingReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Type: issues_model.ReviewTypePending,
Issue: issue,
Reviewer: reviewer,
})
assert.NoError(t, err)
// submitting the pending review must clear the leftover review request,
// otherwise the reviewer can no longer be re-requested afterwards
review, _, err := issues_model.SubmitReview(t.Context(), reviewer, issue, issues_model.ReviewTypeComment, "looks good", "", false, nil)
assert.NoError(t, err)
assert.Equal(t, pendingReview.ID, review.ID)
assert.Equal(t, issues_model.ReviewTypeComment, review.Type)
unittest.AssertNotExistsBean(t, &issues_model.Review{ID: requestReview.ID})
// the reviewer can be re-requested afterwards (no-op before the fix)
comment, err := issues_model.AddReviewRequest(t.Context(), issue, reviewer, doer, false)
assert.NoError(t, err)
assert.NotNil(t, comment)
}
func TestAddReviewRequest(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
assert.NoError(t, pull.LoadIssue(t.Context()))
issue := pull.Issue
assert.NoError(t, issue.LoadRepo(t.Context()))
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
_, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
Issue: issue,
Reviewer: reviewer,
Type: issues_model.ReviewTypeReject,
})
assert.NoError(t, err)
pull.HasMerged = false
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
issue.IsClosed = true
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
pull.HasMerged = true
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
issue.IsClosed = false
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
// Test CODEOWNERS review request stores metadata correctly
pull2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pull2.LoadIssue(t.Context()))
issue2 := pull2.Issue
assert.NoError(t, issue2.LoadRepo(t.Context()))
reviewer2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 7})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
comment, err := issues_model.AddReviewRequest(t.Context(), issue2, reviewer2, doer, true)
assert.NoError(t, err)
assert.NotNil(t, comment)
assert.NotNil(t, comment.CommentMetaData)
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
}
+247
View File
@@ -0,0 +1,247 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"time"
"gitea.dev/models/db"
"gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// Stopwatch represents a stopwatch for time tracking.
type Stopwatch struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"INDEX"`
UserID int64 `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func init() {
db.RegisterModel(new(Stopwatch))
}
// Seconds returns the amount of time passed since creation, based on local server time
func (s Stopwatch) Seconds() int64 {
return int64(timeutil.TimeStampNow() - s.CreatedUnix)
}
func getStopwatch(ctx context.Context, userID, issueID int64) (sw *Stopwatch, exists bool, err error) {
sw = new(Stopwatch)
exists, err = db.GetEngine(ctx).
Where("user_id = ?", userID).
And("issue_id = ?", issueID).
Get(sw)
return sw, exists, err
}
type UserStopwatch struct {
UserID int64
StopWatches []*Stopwatch
}
func GetUIDsAndStopwatch(ctx context.Context) ([]*UserStopwatch, error) {
sws := []*Stopwatch{}
if err := db.GetEngine(ctx).Where("issue_id != 0").Find(&sws); err != nil {
return nil, err
}
if len(sws) == 0 {
return []*UserStopwatch{}, nil
}
lastUserID := int64(-1)
res := []*UserStopwatch{}
for _, sw := range sws {
if lastUserID == sw.UserID {
lastUserStopwatch := res[len(res)-1]
lastUserStopwatch.StopWatches = append(lastUserStopwatch.StopWatches, sw)
} else {
res = append(res, &UserStopwatch{
UserID: sw.UserID,
StopWatches: []*Stopwatch{sw},
})
}
}
return res, nil
}
// GetUserStopwatches return list of the user's all stopwatches
func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOptions) ([]*Stopwatch, error) {
sws := make([]*Stopwatch, 0, 8)
sess := db.GetEngine(ctx).Where("stopwatch.user_id = ?", userID)
if listOptions.Page > 0 {
db.SetSessionPagination(sess, &listOptions)
}
err := sess.Find(&sws)
if err != nil {
return nil, err
}
return sws, nil
}
// CountUserStopwatches return count of the user's all stopwatches
func CountUserStopwatches(ctx context.Context, userID int64) (int64, error) {
return db.GetEngine(ctx).Where("user_id = ?", userID).Count(&Stopwatch{})
}
// StopwatchExists returns true if the stopwatch exists
func StopwatchExists(ctx context.Context, userID, issueID int64) bool {
_, exists, _ := getStopwatch(ctx, userID, issueID)
return exists
}
// HasUserStopwatch returns true if the user has a stopwatch
func HasUserStopwatch(ctx context.Context, userID int64) (exists bool, sw *Stopwatch, issue *Issue, err error) {
type stopwatchIssueRepo struct {
Stopwatch `xorm:"extends"`
Issue `xorm:"extends"`
repo.Repository `xorm:"extends"`
}
swIR := new(stopwatchIssueRepo)
exists, err = db.GetEngine(ctx).
Table("stopwatch").
Where("user_id = ?", userID).
Join("INNER", "issue", "issue.id = stopwatch.issue_id").
Join("INNER", "repository", "repository.id = issue.repo_id").
Get(swIR)
if exists {
sw = &swIR.Stopwatch
issue = &swIR.Issue
issue.Repo = &swIR.Repository
}
return exists, sw, issue, err
}
// FinishIssueStopwatch if stopwatch exists, then finish it.
func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) {
sw, exists, err := getStopwatch(ctx, user.ID, issue.ID)
if err != nil {
return false, err
} else if !exists {
return false, nil
}
if err = finishIssueStopwatch(ctx, user, issue, sw); err != nil {
return false, err
}
return true, nil
}
func finishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue, sw *Stopwatch) error {
// Create tracked time out of the time difference between start date and actual date
timediff := time.Now().Unix() - int64(sw.CreatedUnix)
// Create TrackedTime
tt := &TrackedTime{
Created: time.Now(),
IssueID: issue.ID,
UserID: user.ID,
Time: timediff,
}
if err := issue.LoadRepo(ctx); err != nil {
return err
}
if err := db.Insert(ctx, tt); err != nil {
return err
}
if _, err := CreateComment(ctx, &CreateCommentOptions{
Doer: user,
Issue: issue,
Repo: issue.Repo,
Content: util.SecToHours(timediff),
Type: CommentTypeStopTracking,
TimeID: tt.ID,
}); err != nil {
return err
}
_, err := db.DeleteByBean(ctx, sw)
return err
}
// CreateIssueStopwatch creates a stopwatch if the issue doesn't have the user's stopwatch.
// It also stops any other stopwatch that might be running for the user.
func CreateIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) {
{ // if another issue's stopwatch is running: stop it; if this issue has a stopwatch: return an error.
exists, otherStopWatch, otherIssue, err := HasUserStopwatch(ctx, user.ID)
if err != nil {
return false, err
}
if exists {
if otherStopWatch.IssueID == issue.ID {
// don't allow starting stopwatch for the same issue
return false, nil
}
// stop the other issue's stopwatch
if err = finishIssueStopwatch(ctx, user, otherIssue, otherStopWatch); err != nil {
return false, err
}
}
}
if err = issue.LoadRepo(ctx); err != nil {
return false, err
}
if err = db.Insert(ctx, &Stopwatch{UserID: user.ID, IssueID: issue.ID}); err != nil {
return false, err
}
if _, err = CreateComment(ctx, &CreateCommentOptions{
Doer: user,
Issue: issue,
Repo: issue.Repo,
Type: CommentTypeStartTracking,
}); err != nil {
return false, err
}
return true, nil
}
// CancelStopwatch removes the given stopwatch and logs it into issue's timeline.
func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) {
err = db.WithTx(ctx, func(ctx context.Context) error {
e := db.GetEngine(ctx)
sw, exists, err := getStopwatch(ctx, user.ID, issue.ID)
if err != nil {
return err
} else if !exists {
return nil
}
if err = issue.LoadRepo(ctx); err != nil {
return err
}
if _, err = e.Delete(sw); err != nil {
return err
}
if _, err = CreateComment(ctx, &CreateCommentOptions{
Doer: user,
Issue: issue,
Repo: issue.Repo,
Type: CommentTypeCancelTracking,
}); err != nil {
return err
}
ok = true
return nil
})
return ok, err
}
// RemoveStopwatchesByRepoID removes all stopwatches for a user in a specific repository
// this function should be called before removing all the issues of the repository
func RemoveStopwatchesByRepoID(ctx context.Context, userID, repoID int64) error {
_, err := db.GetEngine(ctx).
Where("`stopwatch`.user_id = ?", userID).
And(builder.In("`stopwatch`.issue_id",
builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}))).
Delete(new(Stopwatch))
return err
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestCancelStopwatch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
ok, err := issues_model.CancelStopwatch(t.Context(), user1, issue1)
assert.NoError(t, err)
assert.True(t, ok)
unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user1.ID, IssueID: issue1.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID})
ok, err = issues_model.CancelStopwatch(t.Context(), user1, issue1)
assert.NoError(t, err)
assert.False(t, ok)
}
func TestStopwatchExists(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, issues_model.StopwatchExists(t.Context(), 1, 1))
assert.False(t, issues_model.StopwatchExists(t.Context(), 1, 2))
}
func TestHasUserStopwatch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
exists, sw, _, err := issues_model.HasUserStopwatch(t.Context(), 1)
assert.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, int64(1), sw.ID)
exists, _, _, err = issues_model.HasUserStopwatch(t.Context(), 3)
assert.NoError(t, err)
assert.False(t, exists)
}
func TestCreateOrStopIssueStopwatch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issue3 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
// create a new stopwatch
ok, err := issues_model.CreateIssueStopwatch(t.Context(), user4, issue1)
assert.NoError(t, err)
assert.True(t, ok)
unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue1.ID})
// should not create a second stopwatch for the same issue
ok, err = issues_model.CreateIssueStopwatch(t.Context(), user4, issue1)
assert.NoError(t, err)
assert.False(t, ok)
// on a different issue, it will finish the existing stopwatch and create a new one
ok, err = issues_model.CreateIssueStopwatch(t.Context(), user4, issue3)
assert.NoError(t, err)
assert.True(t, ok)
unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue1.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue3.ID})
// user2 already has a stopwatch in test fixture
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
ok, err = issues_model.FinishIssueStopwatch(t.Context(), user2, issue2)
assert.NoError(t, err)
assert.True(t, ok)
unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user2.ID, IssueID: issue2.ID})
unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: user2.ID, IssueID: issue2.ID})
ok, err = issues_model.FinishIssueStopwatch(t.Context(), user2, issue2)
assert.NoError(t, err)
assert.False(t, ok)
}
+364
View File
@@ -0,0 +1,364 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"errors"
"fmt"
"time"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// TrackedTime represents a time that was spent for a specific issue.
type TrackedTime struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"INDEX"`
Issue *Issue `xorm:"-"`
UserID int64 `xorm:"INDEX"`
User *user_model.User `xorm:"-"`
Created time.Time `xorm:"-"`
CreatedUnix int64 `xorm:"created"`
Time int64 `xorm:"NOT NULL"`
Deleted bool `xorm:"NOT NULL DEFAULT false"`
}
func init() {
db.RegisterModel(new(TrackedTime))
}
// TrackedTimeList is a List of TrackedTime's
type TrackedTimeList []*TrackedTime
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
func (t *TrackedTime) AfterLoad() {
t.Created = time.Unix(t.CreatedUnix, 0).In(setting.DefaultUILocation)
}
// LoadAttributes load Issue, User
func (t *TrackedTime) LoadAttributes(ctx context.Context) (err error) {
// Load the issue
if t.Issue == nil {
t.Issue, err = GetIssueByID(ctx, t.IssueID)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
}
// Now load the repo for the issue (which we may have just loaded)
if t.Issue != nil {
err = t.Issue.LoadRepo(ctx)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
}
// Load the user
if t.User == nil {
t.User, err = user_model.GetUserByID(ctx, t.UserID)
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
return err
}
t.User = user_model.NewGhostUser()
}
}
return nil
}
// LoadAttributes load Issue, User
func (tl TrackedTimeList) LoadAttributes(ctx context.Context) error {
for _, t := range tl {
if err := t.LoadAttributes(ctx); err != nil {
return err
}
}
return nil
}
// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
type FindTrackedTimesOptions struct {
db.ListOptions
IssueID int64
UserID int64
RepositoryID int64
MilestoneID int64
CreatedAfterUnix int64
CreatedBeforeUnix int64
}
// toCond will convert each condition into a xorm-Cond
func (opts *FindTrackedTimesOptions) ToConds() builder.Cond {
cond := builder.NewCond().And(builder.Eq{"tracked_time.deleted": false})
if opts.IssueID != 0 {
cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
}
if opts.UserID != 0 {
cond = cond.And(builder.Eq{"user_id": opts.UserID})
}
if opts.RepositoryID != 0 {
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
}
if opts.MilestoneID != 0 {
cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
}
if opts.CreatedAfterUnix != 0 {
cond = cond.And(builder.Gte{"tracked_time.created_unix": opts.CreatedAfterUnix})
}
if opts.CreatedBeforeUnix != 0 {
cond = cond.And(builder.Lte{"tracked_time.created_unix": opts.CreatedBeforeUnix})
}
return cond
}
func (opts *FindTrackedTimesOptions) ToJoins() []db.JoinFunc {
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
return []db.JoinFunc{
func(e db.Engine) error {
e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
return nil
},
}
}
return nil
}
// toSession will convert the given options to a xorm Session by using the conditions from toCond and joining with issue table if required
func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine {
sess := e
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
sess = e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
}
sess = sess.Where(opts.ToConds())
if opts.Page > 0 {
db.SetSessionPagination(sess, opts)
}
return sess
}
// GetTrackedTimes returns all tracked times that fit to the given options.
func GetTrackedTimes(ctx context.Context, options *FindTrackedTimesOptions) (trackedTimes TrackedTimeList, err error) {
err = options.toSession(db.GetEngine(ctx)).Find(&trackedTimes)
return trackedTimes, err
}
// CountTrackedTimes returns count of tracked times that fit to the given options.
func CountTrackedTimes(ctx context.Context, opts *FindTrackedTimesOptions) (int64, error) {
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
sess = sess.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
}
return sess.Count(&TrackedTime{})
}
// GetTrackedSeconds return sum of seconds
func GetTrackedSeconds(ctx context.Context, opts FindTrackedTimesOptions) (trackedSeconds int64, err error) {
return opts.toSession(db.GetEngine(ctx)).SumInt(&TrackedTime{}, "time")
}
// AddTime will add the given time (in seconds) to the issue
func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*TrackedTime, error) {
t, err := addTime(ctx, user, issue, amount, created)
if err != nil {
return nil, err
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if _, err := CreateComment(ctx, &CreateCommentOptions{
Issue: issue,
Repo: issue.Repo,
Doer: user,
// Content before v1.21 did store the formatted string instead of seconds,
// so use "|" as delimiter to mark the new format
Content: fmt.Sprintf("|%d", amount),
Type: CommentTypeAddTimeManual,
TimeID: t.ID,
}); err != nil {
return nil, err
}
return t, nil
})
}
func addTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
if created.IsZero() {
created = time.Now()
}
tt := &TrackedTime{
IssueID: issue.ID,
UserID: user.ID,
Time: amount,
Created: created,
}
return tt, db.Insert(ctx, tt)
}
// TotalTimesForEachUser returns the spent time in seconds for each user by an issue
func TotalTimesForEachUser(ctx context.Context, options *FindTrackedTimesOptions) (map[*user_model.User]int64, error) {
trackedTimes, err := GetTrackedTimes(ctx, options)
if err != nil {
return nil, err
}
// Adding total time per user ID
totalTimesByUser := make(map[int64]int64)
for _, t := range trackedTimes {
totalTimesByUser[t.UserID] += t.Time
}
totalTimes := make(map[*user_model.User]int64)
// Fetching User and making time human readable
for userID, total := range totalTimesByUser {
user, err := user_model.GetUserByID(ctx, userID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
continue
}
return nil, err
}
totalTimes[user] = total
}
return totalTimes, nil
}
// DeleteIssueUserTimes deletes times for issue
func DeleteIssueUserTimes(ctx context.Context, issue *Issue, user *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
opts := FindTrackedTimesOptions{
IssueID: issue.ID,
UserID: user.ID,
}
removedTime, err := deleteTimes(ctx, opts)
if err != nil {
return err
}
if removedTime == 0 {
return db.ErrNotExist{Resource: "tracked_time"}
}
if err := issue.LoadRepo(ctx); err != nil {
return err
}
_, err = CreateComment(ctx, &CreateCommentOptions{
Issue: issue,
Repo: issue.Repo,
Doer: user,
// Content before v1.21 did store the formatted string instead of seconds,
// so use "|" as delimiter to mark the new format
Content: fmt.Sprintf("|%d", removedTime),
Type: CommentTypeDeleteTimeManual,
})
return err
})
}
// DeleteTime delete a specific Time
func DeleteTime(ctx context.Context, t *TrackedTime) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := t.LoadAttributes(ctx); err != nil {
return err
}
if err := deleteTime(ctx, t); err != nil {
return err
}
_, err := CreateComment(ctx, &CreateCommentOptions{
Issue: t.Issue,
Repo: t.Issue.Repo,
Doer: t.User,
// Content before v1.21 did store the formatted string instead of seconds,
// so use "|" as delimiter to mark the new format
Content: fmt.Sprintf("|%d", t.Time),
Type: CommentTypeDeleteTimeManual,
})
return err
})
}
func deleteTimes(ctx context.Context, opts FindTrackedTimesOptions) (removedTime int64, err error) {
removedTime, err = GetTrackedSeconds(ctx, opts)
if err != nil || removedTime == 0 {
return removedTime, err
}
_, err = opts.toSession(db.GetEngine(ctx)).Table("tracked_time").Cols("deleted").Update(&TrackedTime{Deleted: true})
return removedTime, err
}
func deleteTime(ctx context.Context, t *TrackedTime) error {
if t.Deleted {
return db.ErrNotExist{Resource: "tracked_time", ID: t.ID}
}
t.Deleted = true
_, err := db.GetEngine(ctx).ID(t.ID).Cols("deleted").Update(t)
return err
}
// GetTrackedTimeByID returns raw TrackedTime without loading attributes by id
func GetTrackedTimeByID(ctx context.Context, issueID, trackedTimeID int64) (*TrackedTime, error) {
time := new(TrackedTime)
has, err := db.GetEngine(ctx).ID(trackedTimeID).Where("issue_id = ?", issueID).Get(time)
if err != nil {
return nil, err
} else if !has {
return nil, db.ErrNotExist{Resource: "tracked_time", ID: trackedTimeID}
}
return time, nil
}
// GetIssueTotalTrackedTime returns the total tracked time for issues by given conditions.
func GetIssueTotalTrackedTime(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool]) (int64, error) {
if len(opts.IssueIDs) <= MaxQueryParameters {
return getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs)
}
// If too long a list of IDs is provided,
// we get the statistics in smaller chunks and get accumulates
var accum int64
for i := 0; i < len(opts.IssueIDs); {
chunk := min(i+MaxQueryParameters, len(opts.IssueIDs))
time, err := getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs[i:chunk])
if err != nil {
return 0, err
}
accum += time
i = chunk
}
return accum, nil
}
func getIssueTotalTrackedTimeChunk(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool], issueIDs []int64) (int64, error) {
sumSession := func(opts *IssuesOptions, issueIDs []int64) db.Session {
sess := db.GetEngine(ctx).
Table("tracked_time").
Where("tracked_time.deleted = ?", false).
Join("INNER", "issue", "tracked_time.issue_id = issue.id")
return applyIssuesOptions(sess, opts, issueIDs)
}
type trackedTime struct {
Time int64
}
session := sumSession(opts, issueIDs)
if isClosed.Has() {
session.And("issue.is_closed = ?", isClosed.Value())
}
return session.SumInt(new(trackedTime), "tracked_time.time")
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
"time"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"github.com/stretchr/testify/assert"
)
func TestAddTime(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
org3, err := user_model.GetUserByID(t.Context(), 3)
assert.NoError(t, err)
issue1, err := issues_model.GetIssueByID(t.Context(), 1)
assert.NoError(t, err)
// 3661 = 1h 1min 1s
trackedTime, err := issues_model.AddTime(t.Context(), org3, issue1, 3661, time.Now())
assert.NoError(t, err)
assert.Equal(t, int64(3), trackedTime.UserID)
assert.Equal(t, int64(1), trackedTime.IssueID)
assert.Equal(t, int64(3661), trackedTime.Time)
tt := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: 3, IssueID: 1})
assert.Equal(t, int64(3661), tt.Time)
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeAddTimeManual, PosterID: 3, IssueID: 1})
assert.Equal(t, "|3661", comment.Content)
}
func TestGetTrackedTimes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// by Issue
times, err := issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: 1})
assert.NoError(t, err)
assert.Len(t, times, 1)
assert.Equal(t, int64(400), times[0].Time)
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: -1})
assert.NoError(t, err)
assert.Empty(t, times)
// by User
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{UserID: 1})
assert.NoError(t, err)
assert.Len(t, times, 3)
assert.Equal(t, int64(400), times[0].Time)
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{UserID: 3})
assert.NoError(t, err)
assert.Empty(t, times)
// by Repo
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{RepositoryID: 2})
assert.NoError(t, err)
assert.Len(t, times, 3)
assert.Equal(t, int64(1), times[0].Time)
issue, err := issues_model.GetIssueByID(t.Context(), times[0].IssueID)
assert.NoError(t, err)
assert.Equal(t, int64(2), issue.RepoID)
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{RepositoryID: 1})
assert.NoError(t, err)
assert.Len(t, times, 5)
times, err = issues_model.GetTrackedTimes(t.Context(), &issues_model.FindTrackedTimesOptions{RepositoryID: 10})
assert.NoError(t, err)
assert.Empty(t, times)
}
func TestTotalTimesForEachUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
total, err := issues_model.TotalTimesForEachUser(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: 1})
assert.NoError(t, err)
assert.Len(t, total, 1)
for user, time := range total {
assert.EqualValues(t, 1, user.ID)
assert.EqualValues(t, 400, time)
}
total, err = issues_model.TotalTimesForEachUser(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: 2})
assert.NoError(t, err)
assert.Len(t, total, 2)
for user, time := range total {
if user.ID == 2 {
assert.EqualValues(t, 3662, time)
} else if user.ID == 1 {
assert.EqualValues(t, 20, time)
} else {
assert.Error(t, assert.AnError)
}
}
total, err = issues_model.TotalTimesForEachUser(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: 5})
assert.NoError(t, err)
assert.Len(t, total, 1)
for user, time := range total {
assert.EqualValues(t, 2, user.ID)
assert.EqualValues(t, 1, time)
}
total, err = issues_model.TotalTimesForEachUser(t.Context(), &issues_model.FindTrackedTimesOptions{IssueID: 4})
assert.NoError(t, err)
assert.Len(t, total, 2)
}
func TestGetIssueTotalTrackedTime(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ttt, err := issues_model.GetIssueTotalTrackedTime(t.Context(), &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}, optional.Some(false))
assert.NoError(t, err)
assert.EqualValues(t, 3682, ttt)
ttt, err = issues_model.GetIssueTotalTrackedTime(t.Context(), &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}, optional.Some(true))
assert.NoError(t, err)
assert.EqualValues(t, 0, ttt)
ttt, err = issues_model.GetIssueTotalTrackedTime(t.Context(), &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}, optional.None[bool]())
assert.NoError(t, err)
assert.EqualValues(t, 3682, ttt)
}