初始提交: Gitea 项目代码
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user