aboutsummaryrefslogtreecommitdiffstats
path: root/models/issues
diff options
context:
space:
mode:
Diffstat (limited to 'models/issues')
-rw-r--r--models/issues/assignees.go171
-rw-r--r--models/issues/assignees_test.go92
-rw-r--r--models/issues/comment.go1546
-rw-r--r--models/issues/comment_list.go553
-rw-r--r--models/issues/comment_test.go65
-rw-r--r--models/issues/content_history.go6
-rw-r--r--models/issues/content_history_test.go43
-rw-r--r--models/issues/dependency.go210
-rw-r--r--models/issues/dependency_test.go63
-rw-r--r--models/issues/issue.go2448
-rw-r--r--models/issues/issue_index.go32
-rw-r--r--models/issues/issue_list.go565
-rw-r--r--models/issues/issue_list_test.go73
-rw-r--r--models/issues/issue_lock.go65
-rw-r--r--models/issues/issue_project.go179
-rw-r--r--models/issues/issue_test.go562
-rw-r--r--models/issues/issue_user.go87
-rw-r--r--models/issues/issue_user_test.go62
-rw-r--r--models/issues/issue_watch.go135
-rw-r--r--models/issues/issue_watch_test.go68
-rw-r--r--models/issues/issue_xref.go357
-rw-r--r--models/issues/issue_xref_test.go184
-rw-r--r--models/issues/label.go836
-rw-r--r--models/issues/label_test.go395
-rw-r--r--models/issues/main_test.go24
-rw-r--r--models/issues/milestone.go33
-rw-r--r--models/issues/milestone_test.go151
-rw-r--r--models/issues/pull.go838
-rw-r--r--models/issues/pull_list.go216
-rw-r--r--models/issues/pull_test.go277
-rw-r--r--models/issues/reaction_test.go31
-rw-r--r--models/issues/review.go1018
-rw-r--r--models/issues/review_test.go203
-rw-r--r--models/issues/stopwatch.go293
-rw-r--r--models/issues/stopwatch_test.go79
-rw-r--r--models/issues/tracked_time.go316
-rw-r--r--models/issues/tracked_time_test.go118
37 files changed, 12291 insertions, 103 deletions
diff --git a/models/issues/assignees.go b/models/issues/assignees.go
new file mode 100644
index 0000000000..5921112fea
--- /dev/null
+++ b/models/issues/assignees.go
@@ -0,0 +1,171 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// 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) {
+ // Reset maybe preexisting assignees
+ issue.Assignees = []*user_model.User{}
+ issue.Assignee = nil
+
+ err = db.GetEngine(ctx).Table("`user`").
+ Join("INNER", "issue_assignees", "assignee_id = `user`.id").
+ Where("issue_assignees.issue_id = ?", issue.ID).
+ Find(&issue.Assignees)
+ if err != nil {
+ return err
+ }
+
+ // 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
+}
+
+// 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(issueID int64) ([]int64, error) {
+ userIDs := make([]int64, 0, 5)
+ return userIDs, db.GetEngine(db.DefaultContext).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.GetByBean(ctx, &IssueAssignees{IssueID: issue.ID, AssigneeID: user.ID})
+}
+
+// ToggleIssueAssignee changes a user between assigned and not assigned for this issue, and make issue comment for it.
+func ToggleIssueAssignee(issue *Issue, doer *user_model.User, assigneeID int64) (removed bool, comment *Comment, err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return false, nil, err
+ }
+ defer committer.Close()
+
+ removed, comment, err = toggleIssueAssignee(ctx, issue, doer, assigneeID, false)
+ if err != nil {
+ return false, nil, err
+ }
+
+ if err := committer.Commit(); 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: %v", err)
+ }
+
+ // Repo infos
+ if err = issue.LoadRepo(ctx); err != nil {
+ return false, nil, fmt.Errorf("loadRepo: %v", err)
+ }
+
+ opts := &CreateCommentOptions{
+ Type: CommentTypeAssignees,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ RemovedAssignee: removed,
+ AssigneeID: assigneeID,
+ }
+ // Comment
+ comment, err = CreateCommentCtx(ctx, opts)
+ if err != nil {
+ return false, nil, fmt.Errorf("createComment: %v", 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.GetUserByIDCtx(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(oneAssignee string, multipleAssignees []string) (assigneeIDs []int64, err error) {
+ var requestAssignees []string
+
+ // Keeping the old assigning method for compatibility reasons
+ if oneAssignee != "" && !util.IsStringInSlice(oneAssignee, multipleAssignees) {
+ 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(requestAssignees, false)
+
+ return
+}
diff --git a/models/issues/assignees_test.go b/models/issues/assignees_test.go
new file mode 100644
index 0000000000..37d966f140
--- /dev/null
+++ b/models/issues/assignees_test.go
@@ -0,0 +1,92 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/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.GetIssueWithAttrsByID(1)
+ assert.NoError(t, err)
+
+ // Assign multiple users
+ user2, err := user_model.GetUserByID(2)
+ assert.NoError(t, err)
+ _, _, err = issues_model.ToggleIssueAssignee(issue, &user_model.User{ID: 1}, user2.ID)
+ assert.NoError(t, err)
+
+ user3, err := user_model.GetUserByID(3)
+ assert.NoError(t, err)
+ _, _, err = issues_model.ToggleIssueAssignee(issue, &user_model.User{ID: 1}, user3.ID)
+ assert.NoError(t, err)
+
+ user1, err := user_model.GetUserByID(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(issue, &user_model.User{ID: 1}, user1.ID)
+ assert.NoError(t, err)
+
+ // Check if he got removed
+ isAssigned, err := issues_model.IsUserAssignedToIssue(db.DefaultContext, issue, user1)
+ assert.NoError(t, err)
+ assert.False(t, isAssigned)
+
+ // Check if they're all there
+ err = issue.LoadAssignees(db.DefaultContext)
+ assert.NoError(t, err)
+
+ var expectedAssignees []*user_model.User
+ expectedAssignees = append(expectedAssignees, user2, user3)
+
+ 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(db.DefaultContext, issue, user2)
+ assert.NoError(t, err)
+ assert.True(t, isAssigned)
+
+ // This user should not be assigned
+ isAssigned, err = issues_model.IsUserAssignedToIssue(db.DefaultContext, 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}).(*user_model.User)
+ _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+
+ IDs, err := issues_model.MakeIDsFromAPIAssigneesToAdd("", []string{""})
+ assert.NoError(t, err)
+ assert.Equal(t, []int64{}, IDs)
+
+ _, err = issues_model.MakeIDsFromAPIAssigneesToAdd("", []string{"none_existing_user"})
+ assert.Error(t, err)
+
+ IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd("user1", []string{"user1"})
+ assert.NoError(t, err)
+ assert.Equal(t, []int64{1}, IDs)
+
+ IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd("user2", []string{""})
+ assert.NoError(t, err)
+ assert.Equal(t, []int64{2}, IDs)
+
+ IDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd("", []string{"user1", "user2"})
+ assert.NoError(t, err)
+ assert.Equal(t, []int64{1, 2}, IDs)
+}
diff --git a/models/issues/comment.go b/models/issues/comment.go
new file mode 100644
index 0000000000..a4e69e7118
--- /dev/null
+++ b/models/issues/comment.go
@@ -0,0 +1,1546 @@
+// Copyright 2018 The Gitea Authors.
+// Copyright 2016 The Gogs Authors.
+// All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/references"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+// ErrCommentNotExist represents a "CommentNotExist" kind of error.
+type ErrCommentNotExist struct {
+ ID int64
+ IssueID int64
+}
+
+// IsErrCommentNotExist checks if an error is a ErrCommentNotExist.
+func IsErrCommentNotExist(err error) bool {
+ _, ok := err.(ErrCommentNotExist)
+ return ok
+}
+
+func (err ErrCommentNotExist) Error() string {
+ return fmt.Sprintf("comment does not exist [id: %d, issue_id: %d]", err.ID, err.IssueID)
+}
+
+// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference.
+type CommentType int
+
+// define unknown comment type
+const (
+ CommentTypeUnknown CommentType = -1
+)
+
+// Enumerate all the comment types
+const (
+ // 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0)
+ CommentTypeComment CommentType = iota
+ CommentTypeReopen // 1
+ CommentTypeClose // 2
+
+ // 3 References.
+ CommentTypeIssueRef
+ // 4 Reference from a commit (not part of a pull request)
+ CommentTypeCommitRef
+ // 5 Reference from a comment
+ CommentTypeCommentRef
+ // 6 Reference from a pull request
+ CommentTypePullRef
+ // 7 Labels changed
+ CommentTypeLabel
+ // 8 Milestone changed
+ CommentTypeMilestone
+ // 9 Assignees changed
+ CommentTypeAssignees
+ // 10 Change Title
+ CommentTypeChangeTitle
+ // 11 Delete Branch
+ CommentTypeDeleteBranch
+ // 12 Start a stopwatch for time tracking
+ CommentTypeStartTracking
+ // 13 Stop a stopwatch for time tracking
+ CommentTypeStopTracking
+ // 14 Add time manual for time tracking
+ CommentTypeAddTimeManual
+ // 15 Cancel a stopwatch for time tracking
+ CommentTypeCancelTracking
+ // 16 Added a due date
+ CommentTypeAddedDeadline
+ // 17 Modified the due date
+ CommentTypeModifiedDeadline
+ // 18 Removed a due date
+ CommentTypeRemovedDeadline
+ // 19 Dependency added
+ CommentTypeAddDependency
+ // 20 Dependency removed
+ CommentTypeRemoveDependency
+ // 21 Comment a line of code
+ CommentTypeCode
+ // 22 Reviews a pull request by giving general feedback
+ CommentTypeReview
+ // 23 Lock an issue, giving only collaborators access
+ CommentTypeLock
+ // 24 Unlocks a previously locked issue
+ CommentTypeUnlock
+ // 25 Change pull request's target branch
+ CommentTypeChangeTargetBranch
+ // 26 Delete time manual for time tracking
+ CommentTypeDeleteTimeManual
+ // 27 add or remove Request from one
+ CommentTypeReviewRequest
+ // 28 merge pull request
+ CommentTypeMergePull
+ // 29 push to PR head branch
+ CommentTypePullRequestPush
+ // 30 Project changed
+ CommentTypeProject
+ // 31 Project board changed
+ CommentTypeProjectBoard
+ // 32 Dismiss Review
+ CommentTypeDismissReview
+ // 33 Change issue ref
+ CommentTypeChangeIssueRef
+ // 34 pr was scheduled to auto merge when checks succeed
+ CommentTypePRScheduledToAutoMerge
+ // 35 pr was un scheduled to auto merge when checks succeed
+ CommentTypePRUnScheduledToAutoMerge
+)
+
+var commentStrings = []string{
+ "comment",
+ "reopen",
+ "close",
+ "issue_ref",
+ "commit_ref",
+ "comment_ref",
+ "pull_ref",
+ "label",
+ "milestone",
+ "assignees",
+ "change_title",
+ "delete_branch",
+ "start_tracking",
+ "stop_tracking",
+ "add_time_manual",
+ "cancel_tracking",
+ "added_deadline",
+ "modified_deadline",
+ "removed_deadline",
+ "add_dependency",
+ "remove_dependency",
+ "code",
+ "review",
+ "lock",
+ "unlock",
+ "change_target_branch",
+ "delete_time_manual",
+ "review_request",
+ "merge_pull",
+ "pull_push",
+ "project",
+ "project_board",
+ "dismiss_review",
+ "change_issue_ref",
+ "pull_scheduled_merge",
+ "pull_cancel_scheduled_merge",
+}
+
+func (t CommentType) String() string {
+ return commentStrings[t]
+}
+
+// RoleDescriptor defines comment tag type
+type RoleDescriptor int
+
+// Enumerate all the role tags.
+const (
+ RoleDescriptorNone RoleDescriptor = iota
+ RoleDescriptorPoster
+ RoleDescriptorWriter
+ RoleDescriptorOwner
+)
+
+// WithRole enable a specific tag on the RoleDescriptor.
+func (rd RoleDescriptor) WithRole(role RoleDescriptor) RoleDescriptor {
+ return rd | (1 << role)
+}
+
+func stringToRoleDescriptor(role string) RoleDescriptor {
+ switch role {
+ case "Poster":
+ return RoleDescriptorPoster
+ case "Writer":
+ return RoleDescriptorWriter
+ case "Owner":
+ return RoleDescriptorOwner
+ default:
+ return RoleDescriptorNone
+ }
+}
+
+// HasRole returns if a certain role is enabled on the RoleDescriptor.
+func (rd RoleDescriptor) HasRole(role string) bool {
+ roleDescriptor := stringToRoleDescriptor(role)
+ bitValue := rd & (1 << roleDescriptor)
+ return (bitValue > 0)
+}
+
+// Comment represents a comment in commit and issue page.
+type Comment struct {
+ ID int64 `xorm:"pk autoincr"`
+ Type CommentType `xorm:"INDEX"`
+ PosterID int64 `xorm:"INDEX"`
+ Poster *user_model.User `xorm:"-"`
+ OriginalAuthor string
+ OriginalAuthorID int64
+ IssueID int64 `xorm:"INDEX"`
+ Issue *Issue `xorm:"-"`
+ LabelID int64
+ Label *Label `xorm:"-"`
+ AddedLabels []*Label `xorm:"-"`
+ RemovedLabels []*Label `xorm:"-"`
+ OldProjectID int64
+ ProjectID int64
+ OldProject *project_model.Project `xorm:"-"`
+ Project *project_model.Project `xorm:"-"`
+ OldMilestoneID int64
+ MilestoneID int64
+ OldMilestone *Milestone `xorm:"-"`
+ Milestone *Milestone `xorm:"-"`
+ TimeID int64
+ Time *TrackedTime `xorm:"-"`
+ AssigneeID int64
+ RemovedAssignee bool
+ Assignee *user_model.User `xorm:"-"`
+ AssigneeTeamID int64 `xorm:"NOT NULL DEFAULT 0"`
+ AssigneeTeam *organization.Team `xorm:"-"`
+ ResolveDoerID int64
+ ResolveDoer *user_model.User `xorm:"-"`
+ OldTitle string
+ NewTitle string
+ OldRef string
+ NewRef string
+ DependentIssueID int64
+ DependentIssue *Issue `xorm:"-"`
+
+ CommitID int64
+ Line int64 // - previous line / + proposed line
+ TreePath string
+ Content string `xorm:"LONGTEXT"`
+ RenderedContent string `xorm:"-"`
+
+ // Path represents the 4 lines of code cemented by this comment
+ Patch string `xorm:"-"`
+ PatchQuoted string `xorm:"LONGTEXT patch"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+
+ // Reference issue in commit message
+ CommitSHA string `xorm:"VARCHAR(40)"`
+
+ Attachments []*repo_model.Attachment `xorm:"-"`
+ Reactions ReactionList `xorm:"-"`
+
+ // For view issue page.
+ ShowRole RoleDescriptor `xorm:"-"`
+
+ Review *Review `xorm:"-"`
+ ReviewID int64 `xorm:"index"`
+ Invalidated bool
+
+ // Reference an issue or pull from another comment, issue or PR
+ // All information is about the origin of the reference
+ RefRepoID int64 `xorm:"index"` // Repo where the referencing
+ RefIssueID int64 `xorm:"index"`
+ RefCommentID int64 `xorm:"index"` // 0 if origin is Issue title or content (or PR's)
+ RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves
+ RefIsPull bool
+
+ RefRepo *repo_model.Repository `xorm:"-"`
+ RefIssue *Issue `xorm:"-"`
+ RefComment *Comment `xorm:"-"`
+
+ Commits []*git_model.SignCommitWithStatuses `xorm:"-"`
+ OldCommit string `xorm:"-"`
+ NewCommit string `xorm:"-"`
+ CommitsNum int64 `xorm:"-"`
+ IsForcePush bool `xorm:"-"`
+}
+
+func init() {
+ db.RegisterModel(new(Comment))
+}
+
+// PushActionContent is content of push pull comment
+type PushActionContent struct {
+ IsForcePush bool `json:"is_force_push"`
+ CommitIDs []string `json:"commit_ids"`
+}
+
+// LoadIssue loads issue from database
+func (c *Comment) LoadIssue() (err error) {
+ return c.LoadIssueCtx(db.DefaultContext)
+}
+
+// LoadIssueCtx loads issue from database
+func (c *Comment) LoadIssueCtx(ctx context.Context) (err error) {
+ if c.Issue != nil {
+ return nil
+ }
+ c.Issue, err = GetIssueByID(ctx, c.IssueID)
+ return
+}
+
+// BeforeInsert will be invoked by XORM before inserting a record
+func (c *Comment) BeforeInsert() {
+ c.PatchQuoted = c.Patch
+ if !utf8.ValidString(c.Patch) {
+ c.PatchQuoted = strconv.Quote(c.Patch)
+ }
+}
+
+// BeforeUpdate will be invoked by XORM before updating a record
+func (c *Comment) BeforeUpdate() {
+ c.PatchQuoted = c.Patch
+ if !utf8.ValidString(c.Patch) {
+ c.PatchQuoted = strconv.Quote(c.Patch)
+ }
+}
+
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
+func (c *Comment) AfterLoad(session *xorm.Session) {
+ c.Patch = c.PatchQuoted
+ if len(c.PatchQuoted) > 0 && c.PatchQuoted[0] == '"' {
+ unquoted, err := strconv.Unquote(c.PatchQuoted)
+ if err == nil {
+ c.Patch = unquoted
+ }
+ }
+}
+
+func (c *Comment) loadPoster(ctx context.Context) (err error) {
+ if c.PosterID <= 0 || c.Poster != nil {
+ return nil
+ }
+
+ c.Poster, err = user_model.GetUserByIDCtx(ctx, c.PosterID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ c.PosterID = -1
+ c.Poster = user_model.NewGhostUser()
+ } else {
+ log.Error("getUserByID[%d]: %v", c.ID, err)
+ }
+ }
+ return err
+}
+
+// AfterDelete is invoked from XORM after the object is deleted.
+func (c *Comment) AfterDelete() {
+ if c.ID <= 0 {
+ return
+ }
+
+ _, err := repo_model.DeleteAttachmentsByComment(c.ID, true)
+ if err != nil {
+ log.Info("Could not delete files for comment %d on issue #%d: %s", c.ID, c.IssueID, err)
+ }
+}
+
+// HTMLURL formats a URL-string to the issue-comment
+func (c *Comment) HTMLURL() string {
+ err := c.LoadIssue()
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadIssue(%d): %v", c.IssueID, err)
+ return ""
+ }
+ err = c.Issue.LoadRepo(db.DefaultContext)
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+ return ""
+ }
+ if c.Type == CommentTypeCode {
+ if c.ReviewID == 0 {
+ return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag())
+ }
+ if c.Review == nil {
+ if err := c.LoadReview(); err != nil {
+ log.Warn("LoadReview(%d): %v", c.ReviewID, err)
+ return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag())
+ }
+ }
+ if c.Review.Type <= ReviewTypePending {
+ return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag())
+ }
+ }
+ return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag())
+}
+
+// APIURL formats a API-string to the issue-comment
+func (c *Comment) APIURL() string {
+ err := c.LoadIssue()
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadIssue(%d): %v", c.IssueID, err)
+ return ""
+ }
+ err = c.Issue.LoadRepo(db.DefaultContext)
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+ return ""
+ }
+
+ return fmt.Sprintf("%s/issues/comments/%d", c.Issue.Repo.APIURL(), c.ID)
+}
+
+// IssueURL formats a URL-string to the issue
+func (c *Comment) IssueURL() string {
+ err := c.LoadIssue()
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadIssue(%d): %v", c.IssueID, err)
+ return ""
+ }
+
+ if c.Issue.IsPull {
+ return ""
+ }
+
+ err = c.Issue.LoadRepo(db.DefaultContext)
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+ return ""
+ }
+ return c.Issue.HTMLURL()
+}
+
+// PRURL formats a URL-string to the pull-request
+func (c *Comment) PRURL() string {
+ err := c.LoadIssue()
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadIssue(%d): %v", c.IssueID, err)
+ return ""
+ }
+
+ err = c.Issue.LoadRepo(db.DefaultContext)
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+ return ""
+ }
+
+ if !c.Issue.IsPull {
+ return ""
+ }
+ return c.Issue.HTMLURL()
+}
+
+// CommentHashTag returns unique hash tag for comment id.
+func CommentHashTag(id int64) string {
+ return fmt.Sprintf("issuecomment-%d", id)
+}
+
+// HashTag returns unique hash tag for comment.
+func (c *Comment) HashTag() string {
+ return CommentHashTag(c.ID)
+}
+
+// EventTag returns unique event hash tag for comment.
+func (c *Comment) EventTag() string {
+ return fmt.Sprintf("event-%d", c.ID)
+}
+
+// LoadLabel if comment.Type is CommentTypeLabel, then load Label
+func (c *Comment) LoadLabel() error {
+ var label Label
+ has, err := db.GetEngine(db.DefaultContext).ID(c.LabelID).Get(&label)
+ if err != nil {
+ return err
+ } else if has {
+ c.Label = &label
+ } else {
+ // Ignore Label is deleted, but not clear this table
+ log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
+ }
+
+ return nil
+}
+
+// LoadProject if comment.Type is CommentTypeProject, then load project.
+func (c *Comment) LoadProject() error {
+ if c.OldProjectID > 0 {
+ var oldProject project_model.Project
+ has, err := db.GetEngine(db.DefaultContext).ID(c.OldProjectID).Get(&oldProject)
+ if err != nil {
+ return err
+ } else if has {
+ c.OldProject = &oldProject
+ }
+ }
+
+ if c.ProjectID > 0 {
+ var project project_model.Project
+ has, err := db.GetEngine(db.DefaultContext).ID(c.ProjectID).Get(&project)
+ if err != nil {
+ return err
+ } else if has {
+ c.Project = &project
+ }
+ }
+
+ return nil
+}
+
+// LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone
+func (c *Comment) LoadMilestone() error {
+ if c.OldMilestoneID > 0 {
+ var oldMilestone Milestone
+ has, err := db.GetEngine(db.DefaultContext).ID(c.OldMilestoneID).Get(&oldMilestone)
+ if err != nil {
+ return err
+ } else if has {
+ c.OldMilestone = &oldMilestone
+ }
+ }
+
+ if c.MilestoneID > 0 {
+ var milestone Milestone
+ has, err := db.GetEngine(db.DefaultContext).ID(c.MilestoneID).Get(&milestone)
+ if err != nil {
+ return err
+ } else if has {
+ c.Milestone = &milestone
+ }
+ }
+ return nil
+}
+
+// LoadPoster loads comment poster
+func (c *Comment) LoadPoster() error {
+ return c.loadPoster(db.DefaultContext)
+}
+
+// LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored)
+func (c *Comment) LoadAttachments() error {
+ if len(c.Attachments) > 0 {
+ return nil
+ }
+
+ var err error
+ c.Attachments, err = repo_model.GetAttachmentsByCommentID(db.DefaultContext, c.ID)
+ if err != nil {
+ log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err)
+ }
+ return nil
+}
+
+// UpdateAttachments update attachments by UUIDs for the comment
+func (c *Comment) UpdateAttachments(uuids []string) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
+ if err != nil {
+ return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err)
+ }
+ for i := 0; i < len(attachments); i++ {
+ attachments[i].IssueID = c.IssueID
+ attachments[i].CommentID = c.ID
+ if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
+ return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err)
+ }
+ }
+ return committer.Commit()
+}
+
+// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
+func (c *Comment) LoadAssigneeUserAndTeam() error {
+ var err error
+
+ if c.AssigneeID > 0 && c.Assignee == nil {
+ c.Assignee, err = user_model.GetUserByIDCtx(db.DefaultContext, c.AssigneeID)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ return err
+ }
+ c.Assignee = user_model.NewGhostUser()
+ }
+ } else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
+ if err = c.LoadIssue(); err != nil {
+ return err
+ }
+
+ if err = c.Issue.LoadRepo(db.DefaultContext); err != nil {
+ return err
+ }
+
+ if err = c.Issue.Repo.GetOwner(db.DefaultContext); err != nil {
+ return err
+ }
+
+ if c.Issue.Repo.Owner.IsOrganization() {
+ c.AssigneeTeam, err = organization.GetTeamByID(db.DefaultContext, c.AssigneeTeamID)
+ if err != nil && !organization.IsErrTeamNotExist(err) {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer
+func (c *Comment) LoadResolveDoer() (err error) {
+ if c.ResolveDoerID == 0 || c.Type != CommentTypeCode {
+ return nil
+ }
+ c.ResolveDoer, err = user_model.GetUserByIDCtx(db.DefaultContext, c.ResolveDoerID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ c.ResolveDoer = user_model.NewGhostUser()
+ err = nil
+ }
+ }
+ return
+}
+
+// IsResolved check if an code comment is resolved
+func (c *Comment) IsResolved() bool {
+ return c.ResolveDoerID != 0 && c.Type == CommentTypeCode
+}
+
+// LoadDepIssueDetails loads Dependent Issue Details
+func (c *Comment) LoadDepIssueDetails() (err error) {
+ if c.DependentIssueID <= 0 || c.DependentIssue != nil {
+ return nil
+ }
+ c.DependentIssue, err = GetIssueByID(db.DefaultContext, c.DependentIssueID)
+ return err
+}
+
+// LoadTime loads the associated time for a CommentTypeAddTimeManual
+func (c *Comment) LoadTime() error {
+ if c.Time != nil || c.TimeID == 0 {
+ return nil
+ }
+ var err error
+ c.Time, err = GetTrackedTimeByID(c.TimeID)
+ return err
+}
+
+func (c *Comment) loadReactions(ctx context.Context, repo *repo_model.Repository) (err error) {
+ if c.Reactions != nil {
+ return nil
+ }
+ c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{
+ IssueID: c.IssueID,
+ CommentID: c.ID,
+ })
+ if err != nil {
+ return err
+ }
+ // Load reaction user data
+ if _, err := c.Reactions.LoadUsers(ctx, repo); err != nil {
+ return err
+ }
+ return nil
+}
+
+// LoadReactions loads comment reactions
+func (c *Comment) LoadReactions(repo *repo_model.Repository) error {
+ return c.loadReactions(db.DefaultContext, repo)
+}
+
+func (c *Comment) loadReview(ctx context.Context) (err error) {
+ if c.Review == nil {
+ if c.Review, err = GetReviewByID(ctx, c.ReviewID); err != nil {
+ return err
+ }
+ }
+ c.Review.Issue = c.Issue
+ return nil
+}
+
+// LoadReview loads the associated review
+func (c *Comment) LoadReview() error {
+ return c.loadReview(db.DefaultContext)
+}
+
+var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
+
+func (c *Comment) checkInvalidation(doer *user_model.User, repo *git.Repository, branch string) error {
+ // FIXME differentiate between previous and proposed line
+ commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine()))
+ if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
+ c.Invalidated = true
+ return UpdateComment(c, doer)
+ }
+ if err != nil {
+ return err
+ }
+ if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
+ c.Invalidated = true
+ return UpdateComment(c, doer)
+ }
+ return nil
+}
+
+// CheckInvalidation checks if the line of code comment got changed by another commit.
+// If the line got changed the comment is going to be invalidated.
+func (c *Comment) CheckInvalidation(repo *git.Repository, doer *user_model.User, branch string) error {
+ return c.checkInvalidation(doer, repo, branch)
+}
+
+// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
+func (c *Comment) DiffSide() string {
+ if c.Line < 0 {
+ return "previous"
+ }
+ return "proposed"
+}
+
+// UnsignedLine returns the LOC of the code comment without + or -
+func (c *Comment) UnsignedLine() uint64 {
+ if c.Line < 0 {
+ return uint64(c.Line * -1)
+ }
+ return uint64(c.Line)
+}
+
+// CodeCommentURL returns the url to a comment in code
+func (c *Comment) CodeCommentURL() string {
+ err := c.LoadIssue()
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadIssue(%d): %v", c.IssueID, err)
+ return ""
+ }
+ err = c.Issue.LoadRepo(db.DefaultContext)
+ if err != nil { // Silently dropping errors :unamused:
+ log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+ return ""
+ }
+ return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag())
+}
+
+// LoadPushCommits Load push commits
+func (c *Comment) LoadPushCommits(ctx context.Context) (err error) {
+ if c.Content == "" || c.Commits != nil || c.Type != CommentTypePullRequestPush {
+ return nil
+ }
+
+ var data PushActionContent
+
+ err = json.Unmarshal([]byte(c.Content), &data)
+ if err != nil {
+ return
+ }
+
+ c.IsForcePush = data.IsForcePush
+
+ if c.IsForcePush {
+ if len(data.CommitIDs) != 2 {
+ return nil
+ }
+ c.OldCommit = data.CommitIDs[0]
+ c.NewCommit = data.CommitIDs[1]
+ } else {
+ repoPath := c.Issue.Repo.RepoPath()
+ gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repoPath)
+ if err != nil {
+ return err
+ }
+ defer closer.Close()
+
+ c.Commits = git_model.ConvertFromGitCommit(gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo)
+ c.CommitsNum = int64(len(c.Commits))
+ }
+
+ return err
+}
+
+// CreateCommentCtx creates comment with context
+func CreateCommentCtx(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) {
+ e := db.GetEngine(ctx)
+ var LabelID int64
+ if opts.Label != nil {
+ LabelID = opts.Label.ID
+ }
+
+ comment := &Comment{
+ Type: opts.Type,
+ PosterID: opts.Doer.ID,
+ Poster: opts.Doer,
+ IssueID: opts.Issue.ID,
+ LabelID: LabelID,
+ OldMilestoneID: opts.OldMilestoneID,
+ MilestoneID: opts.MilestoneID,
+ OldProjectID: opts.OldProjectID,
+ ProjectID: opts.ProjectID,
+ TimeID: opts.TimeID,
+ RemovedAssignee: opts.RemovedAssignee,
+ AssigneeID: opts.AssigneeID,
+ AssigneeTeamID: opts.AssigneeTeamID,
+ CommitID: opts.CommitID,
+ CommitSHA: opts.CommitSHA,
+ Line: opts.LineNum,
+ Content: opts.Content,
+ OldTitle: opts.OldTitle,
+ NewTitle: opts.NewTitle,
+ OldRef: opts.OldRef,
+ NewRef: opts.NewRef,
+ DependentIssueID: opts.DependentIssueID,
+ TreePath: opts.TreePath,
+ ReviewID: opts.ReviewID,
+ Patch: opts.Patch,
+ RefRepoID: opts.RefRepoID,
+ RefIssueID: opts.RefIssueID,
+ RefCommentID: opts.RefCommentID,
+ RefAction: opts.RefAction,
+ RefIsPull: opts.RefIsPull,
+ IsForcePush: opts.IsForcePush,
+ Invalidated: opts.Invalidated,
+ }
+ if _, err = e.Insert(comment); err != nil {
+ return nil, err
+ }
+
+ if err = opts.Repo.GetOwner(ctx); err != nil {
+ return nil, err
+ }
+
+ if err = updateCommentInfos(ctx, opts, comment); err != nil {
+ return nil, err
+ }
+
+ if err = comment.AddCrossReferences(ctx, opts.Doer, false); err != nil {
+ return nil, err
+ }
+
+ return comment, nil
+}
+
+func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment *Comment) (err error) {
+ // Check comment type.
+ switch opts.Type {
+ case CommentTypeCode:
+ if comment.ReviewID != 0 {
+ if comment.Review == nil {
+ if err := comment.loadReview(ctx); err != nil {
+ return err
+ }
+ }
+ if comment.Review.Type <= ReviewTypePending {
+ return nil
+ }
+ }
+ fallthrough
+ case CommentTypeComment:
+ if _, err = db.Exec(ctx, "UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
+ return err
+ }
+ fallthrough
+ case CommentTypeReview:
+ // Check attachments
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
+ if err != nil {
+ return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err)
+ }
+
+ for i := range attachments {
+ attachments[i].IssueID = opts.Issue.ID
+ attachments[i].CommentID = comment.ID
+ // No assign value could be 0, so ignore AllCols().
+ if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil {
+ return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err)
+ }
+ }
+ case CommentTypeReopen, CommentTypeClose:
+ if err = updateIssueClosedNum(ctx, opts.Issue); err != nil {
+ return err
+ }
+ }
+ // update the issue's updated_unix column
+ return UpdateIssueCols(ctx, opts.Issue, "updated_unix")
+}
+
+func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) {
+ var content string
+ var commentType CommentType
+
+ // newDeadline = 0 means deleting
+ if newDeadlineUnix == 0 {
+ commentType = CommentTypeRemovedDeadline
+ content = issue.DeadlineUnix.Format("2006-01-02")
+ } else if issue.DeadlineUnix == 0 {
+ // Check if the new date was added or modified
+ // If the actual deadline is 0 => deadline added
+ commentType = CommentTypeAddedDeadline
+ content = newDeadlineUnix.Format("2006-01-02")
+ } else { // Otherwise modified
+ commentType = CommentTypeModifiedDeadline
+ content = newDeadlineUnix.Format("2006-01-02") + "|" + issue.DeadlineUnix.Format("2006-01-02")
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return nil, err
+ }
+
+ opts := &CreateCommentOptions{
+ Type: commentType,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ Content: content,
+ }
+ comment, err := CreateCommentCtx(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+ return comment, nil
+}
+
+// Creates issue dependency comment
+func createIssueDependencyComment(ctx context.Context, doer *user_model.User, issue, dependentIssue *Issue, add bool) (err error) {
+ cType := CommentTypeAddDependency
+ if !add {
+ cType = CommentTypeRemoveDependency
+ }
+ if err = issue.LoadRepo(ctx); err != nil {
+ return
+ }
+
+ // Make two comments, one in each issue
+ opts := &CreateCommentOptions{
+ Type: cType,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ DependentIssueID: dependentIssue.ID,
+ }
+ if _, err = CreateCommentCtx(ctx, opts); err != nil {
+ return
+ }
+
+ opts = &CreateCommentOptions{
+ Type: cType,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: dependentIssue,
+ DependentIssueID: issue.ID,
+ }
+ _, err = CreateCommentCtx(ctx, opts)
+ return
+}
+
+// CreateCommentOptions defines options for creating comment
+type CreateCommentOptions struct {
+ Type CommentType
+ Doer *user_model.User
+ Repo *repo_model.Repository
+ Issue *Issue
+ Label *Label
+
+ DependentIssueID int64
+ OldMilestoneID int64
+ MilestoneID int64
+ OldProjectID int64
+ ProjectID int64
+ TimeID int64
+ AssigneeID int64
+ AssigneeTeamID int64
+ RemovedAssignee bool
+ OldTitle string
+ NewTitle string
+ OldRef string
+ NewRef string
+ CommitID int64
+ CommitSHA string
+ Patch string
+ LineNum int64
+ TreePath string
+ ReviewID int64
+ Content string
+ Attachments []string // UUIDs of attachments
+ RefRepoID int64
+ RefIssueID int64
+ RefCommentID int64
+ RefAction references.XRefAction
+ RefIsPull bool
+ IsForcePush bool
+ Invalidated bool
+}
+
+// CreateComment creates comment of issue or commit.
+func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ comment, err = CreateCommentCtx(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ if err = committer.Commit(); err != nil {
+ return nil, err
+ }
+
+ return comment, nil
+}
+
+// CreateRefComment creates a commit reference comment to issue.
+func CreateRefComment(doer *user_model.User, repo *repo_model.Repository, issue *Issue, content, commitSHA string) error {
+ if len(commitSHA) == 0 {
+ return fmt.Errorf("cannot create reference with empty commit SHA")
+ }
+
+ // Check if same reference from same commit has already existed.
+ has, err := db.GetEngine(db.DefaultContext).Get(&Comment{
+ Type: CommentTypeCommitRef,
+ IssueID: issue.ID,
+ CommitSHA: commitSHA,
+ })
+ if err != nil {
+ return fmt.Errorf("check reference comment: %v", err)
+ } else if has {
+ return nil
+ }
+
+ _, err = CreateComment(&CreateCommentOptions{
+ Type: CommentTypeCommitRef,
+ Doer: doer,
+ Repo: repo,
+ Issue: issue,
+ CommitSHA: commitSHA,
+ Content: content,
+ })
+ return err
+}
+
+// GetCommentByID returns the comment by given ID.
+func GetCommentByID(ctx context.Context, id int64) (*Comment, error) {
+ c := new(Comment)
+ has, err := db.GetEngine(ctx).ID(id).Get(c)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrCommentNotExist{id, 0}
+ }
+ return c, nil
+}
+
+// FindCommentsOptions describes the conditions to Find comments
+type FindCommentsOptions struct {
+ db.ListOptions
+ RepoID int64
+ IssueID int64
+ ReviewID int64
+ Since int64
+ Before int64
+ Line int64
+ TreePath string
+ Type CommentType
+}
+
+func (opts *FindCommentsOptions) toConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.RepoID > 0 {
+ cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID})
+ }
+ if opts.IssueID > 0 {
+ cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID})
+ }
+ if opts.ReviewID > 0 {
+ cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID})
+ }
+ if opts.Since > 0 {
+ cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since})
+ }
+ if opts.Before > 0 {
+ cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before})
+ }
+ if opts.Type != CommentTypeUnknown {
+ cond = cond.And(builder.Eq{"comment.type": opts.Type})
+ }
+ if opts.Line != 0 {
+ cond = cond.And(builder.Eq{"comment.line": opts.Line})
+ }
+ if len(opts.TreePath) > 0 {
+ cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath})
+ }
+ return cond
+}
+
+// FindComments returns all comments according options
+func FindComments(ctx context.Context, opts *FindCommentsOptions) ([]*Comment, error) {
+ comments := make([]*Comment, 0, 10)
+ sess := db.GetEngine(ctx).Where(opts.toConds())
+ if opts.RepoID > 0 {
+ sess.Join("INNER", "issue", "issue.id = comment.issue_id")
+ }
+
+ if opts.Page != 0 {
+ sess = db.SetSessionPagination(sess, opts)
+ }
+
+ // WARNING: If you change this order you will need to fix createCodeComment
+
+ return comments, sess.
+ Asc("comment.created_unix").
+ Asc("comment.id").
+ Find(&comments)
+}
+
+// CountComments count all comments according options by ignoring pagination
+func CountComments(opts *FindCommentsOptions) (int64, error) {
+ sess := db.GetEngine(db.DefaultContext).Where(opts.toConds())
+ if opts.RepoID > 0 {
+ sess.Join("INNER", "issue", "issue.id = comment.issue_id")
+ }
+ return sess.Count(&Comment{})
+}
+
+// UpdateComment updates information of comment.
+func UpdateComment(c *Comment, doer *user_model.User) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil {
+ return err
+ }
+ if err := c.LoadIssueCtx(ctx); err != nil {
+ return err
+ }
+ if err := c.AddCrossReferences(ctx, doer, true); err != nil {
+ return err
+ }
+ if err := committer.Commit(); err != nil {
+ return fmt.Errorf("Commit: %v", err)
+ }
+
+ return nil
+}
+
+// DeleteComment deletes the comment
+func DeleteComment(ctx context.Context, comment *Comment) error {
+ e := db.GetEngine(ctx)
+ if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
+ return err
+ }
+
+ if _, err := db.DeleteByBean(ctx, &ContentHistory{
+ CommentID: comment.ID,
+ }); err != nil {
+ return err
+ }
+
+ if comment.Type == CommentTypeComment {
+ if _, err := e.ID(comment.IssueID).Decr("num_comments").Update(new(Issue)); err != nil {
+ return err
+ }
+ }
+ if _, err := e.Table("action").
+ Where("comment_id = ?", comment.ID).
+ Update(map[string]interface{}{
+ "is_deleted": true,
+ }); err != nil {
+ return err
+ }
+
+ if err := comment.neuterCrossReferences(ctx); err != nil {
+ return err
+ }
+
+ return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID})
+}
+
+// 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) (CodeComments, error) {
+ return fetchCodeCommentsByReview(ctx, issue, currentUser, nil)
+}
+
+func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review) (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)
+ 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) ([]*Comment, error) {
+ var comments []*Comment
+ if review == nil {
+ review = &Review{ID: 0}
+ }
+ conds := opts.toConds()
+ if 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 := CommentList(comments).loadPosters(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 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
+ }
+ comments[n] = comment
+ n++
+
+ if err := comment.LoadResolveDoer(); err != nil {
+ return nil, err
+ }
+
+ if err := comment.LoadReactions(issue.Repo); err != nil {
+ return nil, err
+ }
+
+ var err error
+ if comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
+ Ctx: ctx,
+ URLPrefix: issue.Repo.Link(),
+ Metas: issue.Repo.ComposeMetas(),
+ }, 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) ([]*Comment, error) {
+ opts := FindCommentsOptions{
+ Type: CommentTypeCode,
+ IssueID: issue.ID,
+ TreePath: treePath,
+ Line: line,
+ }
+ return findCodeComments(ctx, opts, issue, currentUser, nil)
+}
+
+// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id
+func UpdateCommentsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error {
+ _, err := db.GetEngine(db.DefaultContext).Table("comment").
+ Where(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,
+ }),
+ )).
+ And("comment.original_author_id = ?", originalAuthorID).
+ Update(map[string]interface{}{
+ "poster_id": posterID,
+ "original_author": "",
+ "original_author_id": 0,
+ })
+ return err
+}
+
+// CreatePushPullComment create push code to pull base comment
+func CreatePushPullComment(ctx context.Context, pusher *user_model.User, pr *PullRequest, oldCommitID, newCommitID string) (comment *Comment, err error) {
+ if pr.HasMerged || oldCommitID == "" || newCommitID == "" {
+ return nil, nil
+ }
+
+ ops := &CreateCommentOptions{
+ Type: CommentTypePullRequestPush,
+ Doer: pusher,
+ Repo: pr.BaseRepo,
+ }
+
+ var data PushActionContent
+
+ data.CommitIDs, data.IsForcePush, err = getCommitIDsFromRepo(ctx, pr.BaseRepo, oldCommitID, newCommitID, pr.BaseBranch)
+ if err != nil {
+ return nil, err
+ }
+
+ ops.Issue = pr.Issue
+
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return nil, err
+ }
+
+ ops.Content = string(dataJSON)
+
+ comment, err = CreateComment(ops)
+
+ return
+}
+
+// CreateAutoMergeComment is a internal function, only use it for CommentTypePRScheduledToAutoMerge and CommentTypePRUnScheduledToAutoMerge CommentTypes
+func CreateAutoMergeComment(ctx context.Context, typ CommentType, pr *PullRequest, doer *user_model.User) (comment *Comment, err error) {
+ if typ != CommentTypePRScheduledToAutoMerge && typ != CommentTypePRUnScheduledToAutoMerge {
+ return nil, fmt.Errorf("comment type %d cannot be used to create an auto merge comment", typ)
+ }
+ if err = pr.LoadIssueCtx(ctx); err != nil {
+ return
+ }
+
+ if err = pr.LoadBaseRepoCtx(ctx); err != nil {
+ return
+ }
+
+ comment, err = CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: typ,
+ Doer: doer,
+ Repo: pr.BaseRepo,
+ Issue: pr.Issue,
+ })
+ return
+}
+
+// getCommitsFromRepo get commit IDs from repo in between oldCommitID and newCommitID
+// isForcePush will be true if oldCommit isn't on the branch
+// Commit on baseBranch will skip
+func getCommitIDsFromRepo(ctx context.Context, repo *repo_model.Repository, oldCommitID, newCommitID, baseBranch string) (commitIDs []string, isForcePush bool, err error) {
+ repoPath := repo.RepoPath()
+ gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repoPath)
+ if err != nil {
+ return nil, false, err
+ }
+ defer closer.Close()
+
+ oldCommit, err := gitRepo.GetCommit(oldCommitID)
+ if err != nil {
+ return nil, false, err
+ }
+
+ if err = oldCommit.LoadBranchName(); err != nil {
+ return nil, false, err
+ }
+
+ if len(oldCommit.Branch) == 0 {
+ commitIDs = make([]string, 2)
+ commitIDs[0] = oldCommitID
+ commitIDs[1] = newCommitID
+
+ return commitIDs, true, err
+ }
+
+ newCommit, err := gitRepo.GetCommit(newCommitID)
+ if err != nil {
+ return nil, false, err
+ }
+
+ commits, err := newCommit.CommitsBeforeUntil(oldCommitID)
+ if err != nil {
+ return nil, false, err
+ }
+
+ commitIDs = make([]string, 0, len(commits))
+ commitChecks := make(map[string]*commitBranchCheckItem)
+
+ for _, commit := range commits {
+ commitChecks[commit.ID.String()] = &commitBranchCheckItem{
+ Commit: commit,
+ Checked: false,
+ }
+ }
+
+ if err = commitBranchCheck(gitRepo, newCommit, oldCommitID, baseBranch, commitChecks); err != nil {
+ return
+ }
+
+ for i := len(commits) - 1; i >= 0; i-- {
+ commitID := commits[i].ID.String()
+ if item, ok := commitChecks[commitID]; ok && item.Checked {
+ commitIDs = append(commitIDs, commitID)
+ }
+ }
+
+ return
+}
+
+type commitBranchCheckItem struct {
+ Commit *git.Commit
+ Checked bool
+}
+
+func commitBranchCheck(gitRepo *git.Repository, startCommit *git.Commit, endCommitID, baseBranch string, commitList map[string]*commitBranchCheckItem) error {
+ if startCommit.ID.String() == endCommitID {
+ return nil
+ }
+
+ checkStack := make([]string, 0, 10)
+ checkStack = append(checkStack, startCommit.ID.String())
+
+ for len(checkStack) > 0 {
+ commitID := checkStack[0]
+ checkStack = checkStack[1:]
+
+ item, ok := commitList[commitID]
+ if !ok {
+ continue
+ }
+
+ if item.Commit.ID.String() == endCommitID {
+ continue
+ }
+
+ if err := item.Commit.LoadBranchName(); err != nil {
+ return err
+ }
+
+ if item.Commit.Branch == baseBranch {
+ continue
+ }
+
+ if item.Checked {
+ continue
+ }
+
+ item.Checked = true
+
+ parentNum := item.Commit.ParentCount()
+ for i := 0; i < parentNum; i++ {
+ parentCommit, err := item.Commit.Parent(i)
+ if err != nil {
+ return err
+ }
+ checkStack = append(checkStack, parentCommit.ID.String())
+ }
+ }
+ return nil
+}
+
+// RemapExternalUser ExternalUserRemappable interface
+func (c *Comment) RemapExternalUser(externalName string, externalID, userID int64) error {
+ c.OriginalAuthor = externalName
+ c.OriginalAuthorID = externalID
+ c.PosterID = userID
+ return nil
+}
+
+// GetUserID ExternalUserRemappable interface
+func (c *Comment) GetUserID() int64 { return c.PosterID }
+
+// GetExternalName ExternalUserRemappable interface
+func (c *Comment) GetExternalName() string { return c.OriginalAuthor }
+
+// GetExternalID ExternalUserRemappable interface
+func (c *Comment) GetExternalID() int64 { return c.OriginalAuthorID }
+
+// CountCommentTypeLabelWithEmptyLabel count label comments with empty label
+func CountCommentTypeLabelWithEmptyLabel() (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Count(new(Comment))
+}
+
+// FixCommentTypeLabelWithEmptyLabel count label comments with empty label
+func FixCommentTypeLabelWithEmptyLabel() (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Delete(new(Comment))
+}
+
+// CountCommentTypeLabelWithOutsideLabels count label comments with outside label
+func CountCommentTypeLabelWithOutsideLabels() (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where("comment.type = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id))", CommentTypeLabel).
+ Table("comment").
+ Join("inner", "label", "label.id = comment.label_id").
+ Join("inner", "issue", "issue.id = comment.issue_id ").
+ Join("inner", "repository", "issue.repo_id = repository.id").
+ Count()
+}
+
+// FixCommentTypeLabelWithOutsideLabels count label comments with outside label
+func FixCommentTypeLabelWithOutsideLabels() (int64, error) {
+ res, err := db.GetEngine(db.DefaultContext).Exec(`DELETE FROM comment WHERE comment.id IN (
+ SELECT il_too.id FROM (
+ SELECT com.id
+ FROM comment AS com
+ INNER JOIN label ON com.label_id = label.id
+ INNER JOIN issue on issue.id = com.issue_id
+ INNER JOIN repository ON issue.repo_id = repository.id
+ WHERE
+ com.type = ? AND ((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)`, CommentTypeLabel)
+ if err != nil {
+ return 0, err
+ }
+
+ return res.RowsAffected()
+}
diff --git a/models/issues/comment_list.go b/models/issues/comment_list.go
new file mode 100644
index 0000000000..e3406a5cbe
--- /dev/null
+++ b/models/issues/comment_list.go
@@ -0,0 +1,553 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/container"
+)
+
+// CommentList defines a list of comments
+type CommentList []*Comment
+
+func (comments CommentList) getPosterIDs() []int64 {
+ posterIDs := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := posterIDs[comment.PosterID]; !ok {
+ posterIDs[comment.PosterID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(posterIDs)
+}
+
+func (comments CommentList) loadPosters(ctx context.Context) error {
+ if len(comments) == 0 {
+ return nil
+ }
+
+ posterIDs := comments.getPosterIDs()
+ posterMaps := make(map[int64]*user_model.User, len(posterIDs))
+ left := len(posterIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ err := db.GetEngine(ctx).
+ In("id", posterIDs[:limit]).
+ Find(&posterMaps)
+ if err != nil {
+ return err
+ }
+ left -= limit
+ posterIDs = posterIDs[limit:]
+ }
+
+ for _, comment := range comments {
+ if comment.PosterID <= 0 {
+ continue
+ }
+ var ok bool
+ if comment.Poster, ok = posterMaps[comment.PosterID]; !ok {
+ comment.Poster = user_model.NewGhostUser()
+ }
+ }
+ return nil
+}
+
+func (comments CommentList) getCommentIDs() []int64 {
+ ids := make([]int64, 0, len(comments))
+ for _, comment := range comments {
+ ids = append(ids, comment.ID)
+ }
+ return ids
+}
+
+func (comments CommentList) getLabelIDs() []int64 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := ids[comment.LabelID]; !ok {
+ ids[comment.LabelID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+func (comments CommentList) loadLabels(ctx context.Context) error { //nolint
+ if len(comments) == 0 {
+ return nil
+ }
+
+ labelIDs := comments.getLabelIDs()
+ commentLabels := make(map[int64]*Label, len(labelIDs))
+ left := len(labelIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := ids[comment.MilestoneID]; !ok {
+ ids[comment.MilestoneID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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.Milestone = milestoneMaps[issue.MilestoneID]
+ }
+ return nil
+}
+
+func (comments CommentList) getOldMilestoneIDs() []int64 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := ids[comment.OldMilestoneID]; !ok {
+ ids[comment.OldMilestoneID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := ids[comment.AssigneeID]; !ok {
+ ids[comment.AssigneeID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+func (comments CommentList) loadAssignees(ctx context.Context) error {
+ if len(comments) == 0 {
+ return nil
+ }
+
+ assigneeIDs := comments.getAssigneeIDs()
+ assignees := make(map[int64]*user_model.User, len(assigneeIDs))
+ left := len(assigneeIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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]
+ }
+ return nil
+}
+
+// getIssueIDs returns all the issue ids on this comment list which issue hasn't been loaded
+func (comments CommentList) getIssueIDs() []int64 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if comment.Issue != nil {
+ continue
+ }
+ if _, ok := ids[comment.IssueID]; !ok {
+ ids[comment.IssueID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+// 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
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if comment.DependentIssue != nil {
+ continue
+ }
+ if _, ok := ids[comment.DependentIssueID]; !ok {
+ ids[comment.DependentIssueID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+func (comments CommentList) loadDependentIssues(ctx context.Context) error {
+ if len(comments) == 0 {
+ return nil
+ }
+
+ e := db.GetEngine(ctx)
+ issueIDs := comments.getDependentIssueIDs()
+ issues := make(map[int64]*Issue, len(issueIDs))
+ left := len(issueIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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
+}
+
+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.getCommentIDs()
+ left := len(commentsIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).Table("attachment").
+ Join("INNER", "comment", "comment.id = attachment.comment_id").
+ 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 {
+ ids := make(map[int64]struct{}, len(comments))
+ for _, comment := range comments {
+ if _, ok := ids[comment.ReviewID]; !ok {
+ ids[comment.ReviewID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+func (comments CommentList) loadReviews(ctx context.Context) error { //nolint
+ if len(comments) == 0 {
+ return nil
+ }
+
+ reviewIDs := comments.getReviewIDs()
+ reviews := make(map[int64]*Review, len(reviewIDs))
+ left := len(reviewIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).
+ In("id", reviewIDs[:limit]).
+ Rows(new(Review))
+ if err != nil {
+ return err
+ }
+
+ for rows.Next() {
+ var review Review
+ err = rows.Scan(&review)
+ if err != nil {
+ _ = rows.Close()
+ return err
+ }
+
+ reviews[review.ID] = &review
+ }
+ _ = rows.Close()
+
+ left -= limit
+ reviewIDs = reviewIDs[limit:]
+ }
+
+ for _, comment := range comments {
+ comment.Review = reviews[comment.ReviewID]
+ }
+ return nil
+}
+
+// loadAttributes loads all attributes
+func (comments CommentList) loadAttributes(ctx context.Context) (err error) {
+ if err = comments.loadPosters(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadLabels(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadMilestones(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadOldMilestones(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadAssignees(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadAttachments(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadReviews(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadIssues(ctx); err != nil {
+ return
+ }
+
+ if err = comments.loadDependentIssues(ctx); err != nil {
+ return
+ }
+
+ return nil
+}
+
+// LoadAttributes loads attributes of the comments, except for attachments and
+// comments
+func (comments CommentList) LoadAttributes() error {
+ return comments.loadAttributes(db.DefaultContext)
+}
+
+// LoadAttachments loads attachments
+func (comments CommentList) LoadAttachments() error {
+ return comments.loadAttachments(db.DefaultContext)
+}
+
+// LoadPosters loads posters
+func (comments CommentList) LoadPosters() error {
+ return comments.loadPosters(db.DefaultContext)
+}
+
+// LoadIssues loads issues of comments
+func (comments CommentList) LoadIssues() error {
+ return comments.loadIssues(db.DefaultContext)
+}
diff --git a/models/issues/comment_test.go b/models/issues/comment_test.go
new file mode 100644
index 0000000000..06b0b85e3c
--- /dev/null
+++ b/models/issues/comment_test.go
@@ -0,0 +1,65 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCreateComment(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}).(*issues_model.Issue)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}).(*repo_model.Repository)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}).(*user_model.User)
+
+ now := time.Now().Unix()
+ comment, err := issues_model.CreateComment(&issues_model.CreateCommentOptions{
+ Type: issues_model.CommentTypeComment,
+ Doer: doer,
+ Repo: repo,
+ Issue: issue,
+ Content: "Hello",
+ })
+ assert.NoError(t, err)
+ then := time.Now().Unix()
+
+ assert.EqualValues(t, issues_model.CommentTypeComment, comment.Type)
+ assert.EqualValues(t, "Hello", comment.Content)
+ assert.EqualValues(t, issue.ID, comment.IssueID)
+ assert.EqualValues(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}).(*issues_model.Issue)
+ unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
+}
+
+func TestFetchCodeComments(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}).(*issues_model.Issue)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
+ res, err := issues_model.FetchCodeComments(db.DefaultContext, issue, user)
+ 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}).(*user_model.User)
+ res, err = issues_model.FetchCodeComments(db.DefaultContext, issue, user2)
+ assert.NoError(t, err)
+ assert.Len(t, res, 1)
+}
diff --git a/models/issues/content_history.go b/models/issues/content_history.go
index 4c5af13db7..3e321784bd 100644
--- a/models/issues/content_history.go
+++ b/models/issues/content_history.go
@@ -53,13 +53,13 @@ func SaveIssueContentHistory(ctx context.Context, posterID, issueID, commentID i
}
// 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)
+ 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
+// 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) {
+func KeepLimitedContentHistory(ctx context.Context, issueID, commentID int64, limit int) {
type IDEditTime struct {
ID int64
EditedUnix timeutil.TimeStamp
diff --git a/models/issues/content_history_test.go b/models/issues/content_history_test.go
index 3cbc0ad5e0..1218d871d0 100644
--- a/models/issues/content_history_test.go
+++ b/models/issues/content_history_test.go
@@ -2,12 +2,13 @@
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
-package issues
+package issues_test
import (
"testing"
"code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/timeutil"
@@ -20,20 +21,20 @@ func TestContentHistory(t *testing.T) {
dbCtx := db.DefaultContext
timeStampNow := timeutil.TimeStampNow()
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow, "i-a", true)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow.Add(2), "i-b", false)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 0, timeStampNow.Add(7), "i-c", false)
+ _ = 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)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow, "c-a", true)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(5), "c-b", false)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(20), "c-c", false)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(50), "c-d", false)
- _ = SaveIssueContentHistory(dbCtx, 1, 10, 100, timeStampNow.Add(51), "c-e", 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, _ := GetIssueContentHistoryByID(dbCtx, 1)
+ h1, _ := issues_model.GetIssueContentHistoryByID(dbCtx, 1)
assert.EqualValues(t, 1, h1.ID)
- m, _ := QueryIssueContentHistoryEditedCountMap(dbCtx, 10)
+ m, _ := issues_model.QueryIssueContentHistoryEditedCountMap(dbCtx, 10)
assert.Equal(t, 3, m[0])
assert.Equal(t, 5, m[100])
@@ -48,31 +49,31 @@ func TestContentHistory(t *testing.T) {
}
_ = db.GetEngine(dbCtx).Sync2(&User{})
- list1, _ := FetchIssueContentHistoryList(dbCtx, 10, 0)
+ list1, _ := issues_model.FetchIssueContentHistoryList(dbCtx, 10, 0)
assert.Len(t, list1, 3)
- list2, _ := FetchIssueContentHistoryList(dbCtx, 10, 100)
+ list2, _ := issues_model.FetchIssueContentHistoryList(dbCtx, 10, 100)
assert.Len(t, list2, 5)
- hasHistory1, _ := HasIssueContentHistory(dbCtx, 10, 0)
+ hasHistory1, _ := issues_model.HasIssueContentHistory(dbCtx, 10, 0)
assert.True(t, hasHistory1)
- hasHistory2, _ := HasIssueContentHistory(dbCtx, 10, 1)
+ hasHistory2, _ := issues_model.HasIssueContentHistory(dbCtx, 10, 1)
assert.False(t, hasHistory2)
- h6, h6Prev, _ := GetIssueContentHistoryAndPrev(dbCtx, 6)
+ h6, h6Prev, _ := issues_model.GetIssueContentHistoryAndPrev(dbCtx, 6)
assert.EqualValues(t, 6, h6.ID)
assert.EqualValues(t, 5, h6Prev.ID)
// soft-delete
- _ = SoftDeleteIssueContentHistory(dbCtx, 5)
- h6, h6Prev, _ = GetIssueContentHistoryAndPrev(dbCtx, 6)
+ _ = issues_model.SoftDeleteIssueContentHistory(dbCtx, 5)
+ h6, h6Prev, _ = issues_model.GetIssueContentHistoryAndPrev(dbCtx, 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
- keepLimitedContentHistory(dbCtx, 10, 100, 3)
- list1, _ = FetchIssueContentHistoryList(dbCtx, 10, 0)
+ issues_model.KeepLimitedContentHistory(dbCtx, 10, 100, 3)
+ list1, _ = issues_model.FetchIssueContentHistoryList(dbCtx, 10, 0)
assert.Len(t, list1, 3)
- list2, _ = FetchIssueContentHistoryList(dbCtx, 10, 100)
+ 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)
diff --git a/models/issues/dependency.go b/models/issues/dependency.go
new file mode 100644
index 0000000000..d664c0758e
--- /dev/null
+++ b/models/issues/dependency.go
@@ -0,0 +1,210 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// 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)
+}
+
+// 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)
+}
+
+// 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
+}
+
+// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType
+func IsErrUnknownDependencyType(err error) bool {
+ _, ok := err.(ErrUnknownDependencyType)
+ return ok
+}
+
+func (err ErrUnknownDependencyType) Error() string {
+ return fmt.Sprintf("unknown dependency type [type: %d]", err.Type)
+}
+
+// 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(user *user_model.User, issue, dep *Issue) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // Check if it aleready 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
+ if err = createIssueDependencyComment(ctx, user, issue, dep, true); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// RemoveIssueDependency removes a dependency from an issue
+func RemoveIssueDependency(user *user_model.User, issue, dep *Issue, depType DependencyType) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ 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
+ if err = createIssueDependencyComment(ctx, user, issue, dep, false); err != nil {
+ return err
+ }
+ return committer.Commit()
+}
+
+// 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
+}
diff --git a/models/issues/dependency_test.go b/models/issues/dependency_test.go
new file mode 100644
index 0000000000..3ea0b4ff5c
--- /dev/null
+++ b/models/issues/dependency_test.go
@@ -0,0 +1,63 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCreateIssueDependency(t *testing.T) {
+ // Prepare
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ user1, err := user_model.GetUserByID(1)
+ assert.NoError(t, err)
+
+ issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+
+ issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2)
+ assert.NoError(t, err)
+
+ // Create a dependency and check if it was successful
+ err = issues_model.CreateIssueDependency(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(user1, issue1, issue2)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrDependencyExists(err))
+
+ // Check for circular dependencies
+ err = issues_model.CreateIssueDependency(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(db.DefaultContext, issue1)
+ assert.NoError(t, err)
+ assert.False(t, left)
+
+ // Close #2 and check again
+ _, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue2, user1, true)
+ assert.NoError(t, err)
+
+ left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1)
+ assert.NoError(t, err)
+ assert.True(t, left)
+
+ // Test removing the dependency
+ err = issues_model.RemoveIssueDependency(user1, issue1, issue2, issues_model.DependencyTypeBlockedBy)
+ assert.NoError(t, err)
+}
diff --git a/models/issues/issue.go b/models/issues/issue.go
new file mode 100644
index 0000000000..0f4af3e84f
--- /dev/null
+++ b/models/issues/issue.go
@@ -0,0 +1,2448 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ admin_model "code.gitea.io/gitea/models/admin"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/foreignreference"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/references"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+// 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)
+}
+
+// ErrIssueIsClosed represents a "IssueIsClosed" kind of error.
+type ErrIssueIsClosed struct {
+ ID int64
+ RepoID int64
+ Index int64
+}
+
+// IsErrIssueIsClosed checks if an error is a ErrIssueNotExist.
+func IsErrIssueIsClosed(err error) bool {
+ _, ok := err.(ErrIssueIsClosed)
+ return ok
+}
+
+func (err ErrIssueIsClosed) Error() string {
+ return fmt.Sprintf("issue is closed [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index)
+}
+
+// ErrNewIssueInsert is used when the INSERT statement in newIssue fails
+type ErrNewIssueInsert struct {
+ OriginalError error
+}
+
+// IsErrNewIssueInsert checks if an error is a ErrNewIssueInsert.
+func IsErrNewIssueInsert(err error) bool {
+ _, ok := err.(ErrNewIssueInsert)
+ return ok
+}
+
+func (err ErrNewIssueInsert) Error() string {
+ return err.OriginalError.Error()
+}
+
+// ErrIssueWasClosed is used when close a closed issue
+type ErrIssueWasClosed struct {
+ ID int64
+ Index int64
+}
+
+// IsErrIssueWasClosed checks if an error is a ErrIssueWasClosed.
+func IsErrIssueWasClosed(err error) bool {
+ _, ok := err.(ErrIssueWasClosed)
+ return ok
+}
+
+func (err ErrIssueWasClosed) Error() string {
+ return fmt.Sprintf("Issue [%d] %d was already closed", err.ID, err.Index)
+}
+
+// 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 string `xorm:"-"`
+ Labels []*Label `xorm:"-"`
+ MilestoneID int64 `xorm:"INDEX"`
+ Milestone *Milestone `xorm:"-"`
+ Project *project_model.Project `xorm:"-"`
+ Priority int
+ AssigneeID int64 `xorm:"-"`
+ Assignee *user_model.User `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
+ Ref string
+
+ 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:"-"`
+ Comments []*Comment `xorm:"-"`
+ Reactions ReactionList `xorm:"-"`
+ TotalTrackedTime int64 `xorm:"-"`
+ Assignees []*user_model.User `xorm:"-"`
+ ForeignReference *foreignreference.ForeignReference `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:"-"`
+}
+
+var (
+ issueTasksPat *regexp.Regexp
+ issueTasksDonePat *regexp.Regexp
+)
+
+const (
+ issueTasksRegexpStr = `(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`
+ issueTasksDoneRegexpStr = `(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`
+)
+
+// IssueIndex represents the issue index table
+type IssueIndex db.ResourceIndex
+
+func init() {
+ issueTasksPat = regexp.MustCompile(issueTasksRegexpStr)
+ issueTasksDonePat = regexp.MustCompile(issueTasksDoneRegexpStr)
+
+ 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.Repo, err = repo_model.GetRepositoryByIDCtx(ctx, issue.RepoID)
+ if err != nil {
+ return fmt.Errorf("getRepositoryByID [%d]: %v", issue.RepoID, err)
+ }
+ }
+ return nil
+}
+
+// IsTimetrackerEnabled returns true if the repo enables timetracking
+func (issue *Issue) IsTimetrackerEnabled() bool {
+ return issue.isTimetrackerEnabled(db.DefaultContext)
+}
+
+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.IsTimetrackerEnabledCtx(ctx)
+}
+
+// GetPullRequest returns the issue pull request
+func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) {
+ if !issue.IsPull {
+ return nil, fmt.Errorf("Issue is not a pull request")
+ }
+
+ pr, err = GetPullRequestByIssueID(db.DefaultContext, issue.ID)
+ if err != nil {
+ return nil, err
+ }
+ pr.Issue = issue
+ return
+}
+
+// LoadLabels loads labels
+func (issue *Issue) LoadLabels(ctx context.Context) (err error) {
+ if issue.Labels == nil {
+ issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID)
+ if err != nil {
+ return fmt.Errorf("getLabelsByIssueID [%d]: %v", issue.ID, err)
+ }
+ }
+ return nil
+}
+
+// LoadPoster loads poster
+func (issue *Issue) LoadPoster() error {
+ return issue.loadPoster(db.DefaultContext)
+}
+
+func (issue *Issue) loadPoster(ctx context.Context) (err error) {
+ if issue.Poster == nil {
+ issue.Poster, err = user_model.GetUserByIDCtx(ctx, issue.PosterID)
+ if err != nil {
+ issue.PosterID = -1
+ issue.Poster = user_model.NewGhostUser()
+ if !user_model.IsErrUserNotExist(err) {
+ return fmt.Errorf("getUserByID.(poster) [%d]: %v", issue.PosterID, err)
+ }
+ err = nil
+ return
+ }
+ }
+ return
+}
+
+func (issue *Issue) loadPullRequest(ctx context.Context) (err error) {
+ if issue.IsPull && issue.PullRequest == nil {
+ issue.PullRequest, err = GetPullRequestByIssueID(ctx, issue.ID)
+ if err != nil {
+ if IsErrPullRequestNotExist(err) {
+ return err
+ }
+ return fmt.Errorf("getPullRequestByIssueID [%d]: %v", issue.ID, err)
+ }
+ issue.PullRequest.Issue = issue
+ }
+ return nil
+}
+
+// LoadPullRequest loads pull request info
+func (issue *Issue) LoadPullRequest() error {
+ return issue.loadPullRequest(db.DefaultContext)
+}
+
+func (issue *Issue) loadComments(ctx context.Context) (err error) {
+ return issue.loadCommentsByType(ctx, CommentTypeUnknown)
+}
+
+// LoadDiscussComments loads discuss comments
+func (issue *Issue) LoadDiscussComments() error {
+ return issue.loadCommentsByType(db.DefaultContext, 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,
+ })
+ 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 := ReactionList(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
+}
+
+func (issue *Issue) loadForeignReference(ctx context.Context) (err error) {
+ if issue.ForeignReference != nil {
+ return nil
+ }
+ reference := &foreignreference.ForeignReference{
+ RepoID: issue.RepoID,
+ LocalIndex: issue.Index,
+ Type: foreignreference.TypeIssue,
+ }
+ has, err := db.GetEngine(ctx).Get(reference)
+ if err != nil {
+ return err
+ } else if !has {
+ return foreignreference.ErrForeignIndexNotExist{
+ RepoID: issue.RepoID,
+ LocalIndex: issue.Index,
+ Type: foreignreference.TypeIssue,
+ }
+ }
+ issue.ForeignReference = reference
+ return nil
+}
+
+func (issue *Issue) loadMilestone(ctx context.Context) (err error) {
+ if (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]: %v", issue.RepoID, issue.MilestoneID, err)
+ }
+ }
+ 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
+ }
+
+ if err = issue.loadPoster(ctx); err != nil {
+ return
+ }
+
+ if err = issue.LoadLabels(ctx); err != nil {
+ return
+ }
+
+ if err = issue.loadMilestone(ctx); err != nil {
+ return
+ }
+
+ if err = issue.loadProject(ctx); err != nil {
+ return
+ }
+
+ if err = issue.LoadAssignees(ctx); err != nil {
+ return
+ }
+
+ if err = issue.loadPullRequest(ctx); err != nil && !IsErrPullRequestNotExist(err) {
+ // It is possible pull request is not yet created.
+ return err
+ }
+
+ if issue.Attachments == nil {
+ issue.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, issue.ID)
+ if err != nil {
+ return fmt.Errorf("getAttachmentsByIssueID [%d]: %v", issue.ID, err)
+ }
+ }
+
+ if err = issue.loadComments(ctx); err != nil {
+ return err
+ }
+
+ if err = CommentList(issue.Comments).loadAttributes(ctx); err != nil {
+ return err
+ }
+ if issue.isTimetrackerEnabled(ctx) {
+ if err = issue.LoadTotalTimes(ctx); err != nil {
+ return err
+ }
+ }
+
+ if err = issue.loadForeignReference(ctx); err != nil && !foreignreference.IsErrForeignIndexNotExist(err) {
+ return err
+ }
+
+ return issue.loadReactions(ctx)
+}
+
+// LoadMilestone load milestone of this issue.
+func (issue *Issue) LoadMilestone() error {
+ return issue.loadMilestone(db.DefaultContext)
+}
+
+// GetIsRead load the `IsRead` field of the issue
+func (issue *Issue) GetIsRead(userID int64) error {
+ issueUser := &IssueUser{IssueID: issue.ID, UID: userID}
+ if has, err := db.GetEngine(db.DefaultContext).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() string {
+ if issue.Repo == nil {
+ err := issue.LoadRepo(db.DefaultContext)
+ 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() string {
+ var path string
+ if issue.IsPull {
+ path = "pulls"
+ } else {
+ path = "issues"
+ }
+ return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index)
+}
+
+// Link returns the Link URL to this issue.
+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
+}
+
+func (issue *Issue) getLabels(ctx context.Context) (err error) {
+ if len(issue.Labels) > 0 {
+ return nil
+ }
+
+ issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID)
+ if err != nil {
+ return fmt.Errorf("getLabelsByIssueID: %v", err)
+ }
+ return nil
+}
+
+func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) {
+ if err = issue.getLabels(ctx); err != nil {
+ return fmt.Errorf("getLabels: %v", err)
+ }
+
+ for i := range issue.Labels {
+ if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil {
+ return fmt.Errorf("removeLabel: %v", err)
+ }
+ }
+
+ return nil
+}
+
+// ClearIssueLabels removes all issue labels as the given user.
+// Triggers appropriate WebHooks, if any.
+func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ } else if err = issue.loadPullRequest(ctx); err != nil {
+ return err
+ }
+
+ perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
+ if err != nil {
+ return err
+ }
+ if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
+ return ErrRepoLabelNotExist{}
+ }
+
+ if err = clearIssueLabels(ctx, issue, doer); err != nil {
+ return err
+ }
+
+ if err = committer.Commit(); err != nil {
+ return fmt.Errorf("Commit: %v", err)
+ }
+
+ return nil
+}
+
+type labelSorter []*Label
+
+func (ts labelSorter) Len() int {
+ return len([]*Label(ts))
+}
+
+func (ts labelSorter) Less(i, j int) bool {
+ return []*Label(ts)[i].ID < []*Label(ts)[j].ID
+}
+
+func (ts labelSorter) Swap(i, j int) {
+ []*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
+}
+
+// ReplaceIssueLabels removes all current labels and add new labels to the issue.
+// Triggers appropriate WebHooks, if any.
+func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ if err = issue.LoadLabels(ctx); err != nil {
+ return err
+ }
+
+ 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: %v", err)
+ }
+ }
+
+ for _, l := range toRemove {
+ if err = deleteIssueLabel(ctx, issue, l, doer); err != nil {
+ return fmt.Errorf("removeLabel: %v", err)
+ }
+ }
+
+ issue.Labels = nil
+ if err = issue.LoadLabels(ctx); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// UpdateIssueCols updates cols of issue
+func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error {
+ if _, err := db.GetEngine(ctx).ID(issue.ID).Cols(cols...).Update(issue); err != nil {
+ return err
+ }
+ return nil
+}
+
+func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed, isMergePull bool) (*Comment, error) {
+ // Reload the issue
+ currentIssue, err := GetIssueByID(ctx, issue.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Nothing should be performed if current status is same as target status
+ if currentIssue.IsClosed == isClosed {
+ if !issue.IsPull {
+ return nil, ErrIssueWasClosed{
+ ID: issue.ID,
+ }
+ }
+ return nil, ErrPullWasClosed{
+ ID: issue.ID,
+ }
+ }
+
+ issue.IsClosed = isClosed
+ return doChangeIssueStatus(ctx, issue, doer, isMergePull)
+}
+
+func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) {
+ // Check for open dependencies
+ if issue.IsClosed && issue.Repo.IsDependenciesEnabledCtx(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}
+ }
+ }
+
+ if issue.IsClosed {
+ issue.ClosedUnix = timeutil.TimeStampNow()
+ } else {
+ issue.ClosedUnix = 0
+ }
+
+ if err := UpdateIssueCols(ctx, issue, "is_closed", "closed_unix"); err != nil {
+ return nil, err
+ }
+
+ // Update issue count of labels
+ if err := issue.getLabels(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
+ }
+ }
+
+ if err := updateIssueClosedNum(ctx, issue); err != nil {
+ return nil, err
+ }
+
+ // New action comment
+ cmtType := CommentTypeClose
+ if !issue.IsClosed {
+ cmtType = CommentTypeReopen
+ } else if isMergePull {
+ cmtType = CommentTypeMergePull
+ }
+
+ return CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: cmtType,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ })
+}
+
+// ChangeIssueStatus changes issue status to open or closed.
+func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed bool) (*Comment, error) {
+ if err := issue.LoadRepo(ctx); err != nil {
+ return nil, err
+ }
+ if err := issue.loadPoster(ctx); err != nil {
+ return nil, err
+ }
+
+ return changeIssueStatus(ctx, issue, doer, isClosed, false)
+}
+
+// ChangeIssueTitle changes the title of this issue, as the given user.
+func ChangeIssueTitle(issue *Issue, doer *user_model.User, oldTitle string) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = UpdateIssueCols(ctx, issue, "name"); err != nil {
+ return fmt.Errorf("updateIssueCols: %v", err)
+ }
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return fmt.Errorf("loadRepo: %v", err)
+ }
+
+ opts := &CreateCommentOptions{
+ Type: CommentTypeChangeTitle,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ OldTitle: oldTitle,
+ NewTitle: issue.Title,
+ }
+ if _, err = CreateCommentCtx(ctx, opts); err != nil {
+ return fmt.Errorf("createComment: %v", err)
+ }
+ if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// ChangeIssueRef changes the branch of this issue, as the given user.
+func ChangeIssueRef(issue *Issue, doer *user_model.User, oldRef string) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = UpdateIssueCols(ctx, issue, "ref"); err != nil {
+ return fmt.Errorf("updateIssueCols: %v", err)
+ }
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return fmt.Errorf("loadRepo: %v", 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 = CreateCommentCtx(ctx, opts); err != nil {
+ return fmt.Errorf("createComment: %v", err)
+ }
+
+ return committer.Commit()
+}
+
+// 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 = CreateCommentCtx(ctx, opts)
+ return err
+}
+
+// UpdateIssueAttachments update attachments by UUIDs for the issue
+func UpdateIssueAttachments(issueID int64, uuids []string) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
+ if err != nil {
+ return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err)
+ }
+ for i := 0; i < len(attachments); i++ {
+ attachments[i].IssueID = issueID
+ if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
+ return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err)
+ }
+ }
+ return committer.Commit()
+}
+
+// ChangeIssueContent changes issue content, as the given user.
+func ChangeIssueContent(issue *Issue, doer *user_model.User, content string) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ hasContentHistory, err := HasIssueContentHistory(ctx, issue.ID, 0)
+ if err != nil {
+ return fmt.Errorf("HasIssueContentHistory: %v", err)
+ }
+ if !hasContentHistory {
+ if err = SaveIssueContentHistory(ctx, issue.PosterID, issue.ID, 0,
+ issue.CreatedUnix, issue.Content, true); err != nil {
+ return fmt.Errorf("SaveIssueContentHistory: %v", err)
+ }
+ }
+
+ issue.Content = content
+
+ if err = UpdateIssueCols(ctx, issue, "content"); err != nil {
+ return fmt.Errorf("UpdateIssueCols: %v", err)
+ }
+
+ if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0,
+ timeutil.TimeStampNow(), issue.Content, false); err != nil {
+ return fmt.Errorf("SaveIssueContentHistory: %v", err)
+ }
+
+ if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
+ return fmt.Errorf("addCrossReferences: %v", err)
+ }
+
+ return committer.Commit()
+}
+
+// GetTasks returns the amount of tasks in the issues content
+func (issue *Issue) GetTasks() int {
+ return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
+}
+
+// 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() (*Comment, error) {
+ var c Comment
+ exist, err := db.GetEngine(db.DefaultContext).Where("type = ?", CommentTypeComment).
+ And("issue_id = ?", issue.ID).Desc("created_unix").Get(&c)
+ if err != nil {
+ return nil, err
+ }
+ if !exist {
+ return nil, nil
+ }
+ 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"
+}
+
+// NewIssueOptions represents the options of a new issue.
+type NewIssueOptions struct {
+ Repo *repo_model.Repository
+ Issue *Issue
+ LabelIDs []int64
+ Attachments []string // In UUID format.
+ IsPull bool
+}
+
+// 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: %v", 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 fmt.Errorf("no issue index provided")
+ }
+ if opts.Issue.ID > 0 {
+ return fmt.Errorf("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 = CreateCommentCtx(ctx, opts); err != nil {
+ return err
+ }
+ }
+
+ if opts.IsPull {
+ _, err = e.Exec("UPDATE `repository` SET num_pulls = num_pulls + 1 WHERE id = ?", opts.Issue.RepoID)
+ } else {
+ _, err = e.Exec("UPDATE `repository` SET num_issues = num_issues + 1 WHERE id = ?", opts.Issue.RepoID)
+ }
+ if 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]: %v", 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]: %v", label.ID, err)
+ }
+ }
+ }
+
+ if err = NewIssueUsers(ctx, opts.Repo, opts.Issue); err != nil {
+ return err
+ }
+
+ if len(opts.Attachments) > 0 {
+ attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
+ if err != nil {
+ return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err)
+ }
+
+ for i := 0; i < len(attachments); i++ {
+ attachments[i].IssueID = opts.Issue.ID
+ if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil {
+ return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, 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.
+func NewIssue(repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) {
+ idx, err := db.GetNextResourceIndex("issue_index", repo.ID)
+ if err != nil {
+ return fmt.Errorf("generate issue index failed: %v", err)
+ }
+
+ issue.Index = idx
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
+ Repo: repo,
+ Issue: issue,
+ LabelIDs: labelIDs,
+ Attachments: uuids,
+ }); err != nil {
+ if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
+ return err
+ }
+ return fmt.Errorf("newIssue: %v", err)
+ }
+
+ if err = committer.Commit(); err != nil {
+ return fmt.Errorf("Commit: %v", err)
+ }
+
+ return nil
+}
+
+// GetIssueByIndex returns raw issue without loading attributes by index in a repository.
+func GetIssueByIndex(repoID, index int64) (*Issue, error) {
+ if index < 1 {
+ return nil, ErrIssueNotExist{}
+ }
+ issue := &Issue{
+ RepoID: repoID,
+ Index: index,
+ }
+ has, err := db.GetEngine(db.DefaultContext).Get(issue)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrIssueNotExist{0, repoID, index}
+ }
+ return issue, nil
+}
+
+// GetIssueByForeignIndex returns raw issue by foreign ID
+func GetIssueByForeignIndex(ctx context.Context, repoID, foreignIndex int64) (*Issue, error) {
+ reference := &foreignreference.ForeignReference{
+ RepoID: repoID,
+ ForeignIndex: strconv.FormatInt(foreignIndex, 10),
+ Type: foreignreference.TypeIssue,
+ }
+ has, err := db.GetEngine(ctx).Get(reference)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, foreignreference.ErrLocalIndexNotExist{
+ RepoID: repoID,
+ ForeignIndex: foreignIndex,
+ Type: foreignreference.TypeIssue,
+ }
+ }
+ return GetIssueByIndex(repoID, reference.LocalIndex)
+}
+
+// GetIssueWithAttrsByIndex returns issue by index in a repository.
+func GetIssueWithAttrsByIndex(repoID, index int64) (*Issue, error) {
+ issue, err := GetIssueByIndex(repoID, index)
+ if err != nil {
+ return nil, err
+ }
+ return issue, issue.LoadAttributes(db.DefaultContext)
+}
+
+// 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
+}
+
+// GetIssueWithAttrsByID returns an issue with attributes by given ID.
+func GetIssueWithAttrsByID(id int64) (*Issue, error) {
+ issue, err := GetIssueByID(db.DefaultContext, id)
+ if err != nil {
+ return nil, err
+ }
+ return issue, issue.LoadAttributes(db.DefaultContext)
+}
+
+// GetIssuesByIDs return issues with the given IDs.
+func GetIssuesByIDs(ctx context.Context, issueIDs []int64) ([]*Issue, error) {
+ issues := make([]*Issue, 0, 10)
+ return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues)
+}
+
+// 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
+}
+
+// IssuesOptions represents options of an issue.
+type IssuesOptions struct { //nolint
+ db.ListOptions
+ RepoID int64 // overwrites RepoCond if not 0
+ RepoCond builder.Cond
+ AssigneeID int64
+ PosterID int64
+ MentionedID int64
+ ReviewRequestedID int64
+ MilestoneIDs []int64
+ ProjectID int64
+ ProjectBoardID int64
+ IsClosed util.OptionalBool
+ IsPull util.OptionalBool
+ LabelIDs []int64
+ IncludedLabelNames []string
+ ExcludedLabelNames []string
+ IncludeMilestones []string
+ SortType string
+ IssueIDs []int64
+ UpdatedAfterUnix int64
+ UpdatedBeforeUnix int64
+ // prioritize issues from this repo
+ PriorityRepoID int64
+ IsArchived util.OptionalBool
+ Org *organization.Organization // issues permission scope
+ Team *organization.Team // issues permission scope
+ User *user_model.User // issues permission scope
+}
+
+// sortIssuesSession sort an issues-related session based on the provided
+// sortType string
+func sortIssuesSession(sess *xorm.Session, sortType string, priorityRepoID int64) {
+ 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 "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").
+ Desc("issue.created_unix").
+ Desc("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 (opts *IssuesOptions) setupSessionWithLimit(sess *xorm.Session) {
+ if opts.Page >= 0 && opts.PageSize > 0 {
+ var start int
+ if opts.Page == 0 {
+ start = 0
+ } else {
+ start = (opts.Page - 1) * opts.PageSize
+ }
+ sess.Limit(opts.PageSize, start)
+ }
+ opts.setupSessionNoLimit(sess)
+}
+
+func (opts *IssuesOptions) setupSessionNoLimit(sess *xorm.Session) {
+ if len(opts.IssueIDs) > 0 {
+ sess.In("issue.id", opts.IssueIDs)
+ }
+
+ if opts.RepoID != 0 {
+ opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoID}
+ }
+ if opts.RepoCond != nil {
+ sess.And(opts.RepoCond)
+ }
+
+ if !opts.IsClosed.IsNone() {
+ sess.And("issue.is_closed=?", opts.IsClosed.IsTrue())
+ }
+
+ if opts.AssigneeID > 0 {
+ applyAssigneeCondition(sess, opts.AssigneeID)
+ }
+
+ if opts.PosterID > 0 {
+ applyPosterCondition(sess, opts.PosterID)
+ }
+
+ if opts.MentionedID > 0 {
+ applyMentionedCondition(sess, opts.MentionedID)
+ }
+
+ if opts.ReviewRequestedID > 0 {
+ applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
+ }
+
+ if len(opts.MilestoneIDs) > 0 {
+ sess.In("issue.milestone_id", opts.MilestoneIDs)
+ }
+
+ 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})
+ }
+
+ if opts.ProjectID > 0 {
+ sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
+ And("project_issue.project_id=?", opts.ProjectID)
+ }
+
+ if opts.ProjectBoardID != 0 {
+ if opts.ProjectBoardID > 0 {
+ sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID}))
+ } else {
+ sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
+ }
+ }
+
+ switch opts.IsPull {
+ case util.OptionalBoolTrue:
+ sess.And("issue.is_pull=?", true)
+ case util.OptionalBoolFalse:
+ sess.And("issue.is_pull=?", false)
+ }
+
+ if opts.IsArchived != util.OptionalBoolNone {
+ sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()})
+ }
+
+ if opts.LabelIDs != nil {
+ for i, labelID := range opts.LabelIDs {
+ if labelID > 0 {
+ sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
+ fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
+ } else {
+ sess.Where("issue.id not in (select issue_id from issue_label where label_id = ?)", -labelID)
+ }
+ }
+ }
+
+ 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)))
+ }
+
+ if len(opts.IncludeMilestones) > 0 {
+ sess.In("issue.milestone_id",
+ builder.Select("id").
+ From("milestone").
+ Where(builder.In("name", opts.IncludeMilestones)))
+ }
+
+ if opts.User != nil {
+ sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.IsTrue()))
+ }
+}
+
+// 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, org *organization.Organization, team *organization.Team, isPull bool) builder.Cond {
+ cond := builder.NewCond()
+ unitType := unit.TypeIssues
+ if isPull {
+ unitType = unit.TypePullRequests
+ }
+ if org != nil {
+ if team != nil {
+ cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, org.ID, team.ID, unitType)) // special team member repos
+ } else {
+ cond = cond.And(
+ builder.Or(
+ repo_model.UserOrgUnitRepoCond(repoIDstr, userID, org.ID, unitType), // team member repos
+ repo_model.UserOrgPublicUnitRepoCond(userID, org.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.UserCollaborationRepoCond(repoIDstr, userID), // collaboration repos
+ 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 *xorm.Session, assigneeID int64) *xorm.Session {
+ return sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
+ And("issue_assignees.assignee_id = ?", assigneeID)
+}
+
+func applyPosterCondition(sess *xorm.Session, posterID int64) *xorm.Session {
+ return sess.And("issue.poster_id=?", posterID)
+}
+
+func applyMentionedCondition(sess *xorm.Session, mentionedID int64) *xorm.Session {
+ return 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 *xorm.Session, reviewRequestedID int64) *xorm.Session {
+ return sess.Join("INNER", []string{"review", "r"}, "issue.id = r.issue_id").
+ And("issue.poster_id <> ?", reviewRequestedID).
+ And("r.type = ?", ReviewTypeRequest).
+ And("r.reviewer_id = ? and r.id in (select max(id) from review where issue_id = r.issue_id and reviewer_id = r.reviewer_id and type in (?, ?, ?))"+
+ " or r.reviewer_team_id in (select team_id from team_user where uid = ?)",
+ reviewRequestedID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, reviewRequestedID)
+}
+
+// CountIssuesByRepo map from repoID to number of issues matching the options
+func CountIssuesByRepo(opts *IssuesOptions) (map[int64]int64, error) {
+ e := db.GetEngine(db.DefaultContext)
+
+ sess := e.Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
+
+ opts.setupSessionNoLimit(sess)
+
+ countsSlice := make([]*struct {
+ RepoID int64
+ Count int64
+ }, 0, 10)
+ if err := sess.GroupBy("issue.repo_id").
+ Select("issue.repo_id AS repo_id, COUNT(*) AS count").
+ Table("issue").
+ Find(&countsSlice); err != nil {
+ return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err)
+ }
+
+ countMap := make(map[int64]int64, len(countsSlice))
+ for _, c := range countsSlice {
+ countMap[c.RepoID] = c.Count
+ }
+ return countMap, nil
+}
+
+// GetRepoIDsForIssuesOptions find all repo ids for the given options
+func GetRepoIDsForIssuesOptions(opts *IssuesOptions, user *user_model.User) ([]int64, error) {
+ repoIDs := make([]int64, 0, 5)
+ e := db.GetEngine(db.DefaultContext)
+
+ sess := e.Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
+
+ opts.setupSessionNoLimit(sess)
+
+ accessCond := repo_model.AccessibleRepositoryCondition(user)
+ if err := sess.Where(accessCond).
+ Distinct("issue.repo_id").
+ Table("issue").
+ Find(&repoIDs); err != nil {
+ return nil, fmt.Errorf("unable to GetRepoIDsForIssuesOptions: %w", err)
+ }
+
+ return repoIDs, nil
+}
+
+// Issues returns a list of issues by given conditions.
+func Issues(opts *IssuesOptions) ([]*Issue, error) {
+ e := db.GetEngine(db.DefaultContext)
+
+ sess := e.Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
+ opts.setupSessionWithLimit(sess)
+
+ sortIssuesSession(sess, opts.SortType, opts.PriorityRepoID)
+
+ issues := make([]*Issue, 0, opts.ListOptions.PageSize)
+ if err := sess.Find(&issues); err != nil {
+ return nil, fmt.Errorf("unable to query Issues: %w", err)
+ }
+
+ if err := IssueList(issues).LoadAttributes(); err != nil {
+ return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err)
+ }
+
+ return issues, nil
+}
+
+// CountIssues number return of issues by given conditions.
+func CountIssues(opts *IssuesOptions) (int64, error) {
+ e := db.GetEngine(db.DefaultContext)
+
+ sess := e.Select("COUNT(issue.id) AS count").Table("issue")
+ sess.Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
+ opts.setupSessionNoLimit(sess)
+
+ return sess.Count()
+}
+
+// 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(issueID int64) ([]int64, error) {
+ userIDs := make([]int64, 0, 5)
+ return userIDs, db.GetEngine(db.DefaultContext).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(user *user_model.User, issue *Issue) bool {
+ userIDs, err := issue.GetParticipantIDsByIssue(db.DefaultContext)
+ if err != nil {
+ log.Error(err.Error())
+ return false
+ }
+ return util.IsInt64InSlice(user.ID, userIDs)
+}
+
+// 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: %v", err)
+ }
+ return nil
+}
+
+// IssueStats represents issue statistic information.
+type IssueStats struct {
+ OpenCount, ClosedCount int64
+ YourRepositoriesCount int64
+ AssignCount int64
+ CreateCount int64
+ MentionCount int64
+ ReviewRequestedCount int64
+}
+
+// Filter modes.
+const (
+ FilterModeAll = iota
+ FilterModeAssign
+ FilterModeCreate
+ FilterModeMention
+ FilterModeReviewRequested
+ FilterModeYourRepositories
+)
+
+// IssueStatsOptions contains parameters accepted by GetIssueStats.
+type IssueStatsOptions struct {
+ RepoID int64
+ Labels string
+ MilestoneID int64
+ AssigneeID int64
+ MentionedID int64
+ PosterID int64
+ ReviewRequestedID int64
+ IsPull util.OptionalBool
+ IssueIDs []int64
+}
+
+const (
+ // MaxQueryParameters represents the max query parameters
+ // When queries are broken down in parts because of the number
+ // of parameters, attempt to break by this amount
+ MaxQueryParameters = 300
+)
+
+// GetIssueStats returns issue statistic information by given conditions.
+func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) {
+ if len(opts.IssueIDs) <= MaxQueryParameters {
+ return getIssueStatsChunk(opts, opts.IssueIDs)
+ }
+
+ // If too long a list of IDs is provided, we get the statistics in
+ // smaller chunks and get accumulates. Note: this could potentially
+ // get us invalid results. The alternative is to insert the list of
+ // ids in a temporary table and join from them.
+ accum := &IssueStats{}
+ for i := 0; i < len(opts.IssueIDs); {
+ chunk := i + MaxQueryParameters
+ if chunk > len(opts.IssueIDs) {
+ chunk = len(opts.IssueIDs)
+ }
+ stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk])
+ if err != nil {
+ return nil, err
+ }
+ accum.OpenCount += stats.OpenCount
+ accum.ClosedCount += stats.ClosedCount
+ accum.YourRepositoriesCount += stats.YourRepositoriesCount
+ accum.AssignCount += stats.AssignCount
+ accum.CreateCount += stats.CreateCount
+ accum.OpenCount += stats.MentionCount
+ accum.ReviewRequestedCount += stats.ReviewRequestedCount
+ i = chunk
+ }
+ return accum, nil
+}
+
+func getIssueStatsChunk(opts *IssueStatsOptions, issueIDs []int64) (*IssueStats, error) {
+ stats := &IssueStats{}
+
+ countSession := func(opts *IssueStatsOptions, issueIDs []int64) *xorm.Session {
+ sess := db.GetEngine(db.DefaultContext).
+ Where("issue.repo_id = ?", opts.RepoID)
+
+ if len(issueIDs) > 0 {
+ sess.In("issue.id", issueIDs)
+ }
+
+ if len(opts.Labels) > 0 && opts.Labels != "0" {
+ labelIDs, err := base.StringsToInt64s(strings.Split(opts.Labels, ","))
+ if err != nil {
+ log.Warn("Malformed Labels argument: %s", opts.Labels)
+ } else {
+ for i, labelID := range labelIDs {
+ if labelID > 0 {
+ sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
+ fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
+ } else {
+ sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label WHERE label_id = ?)", -labelID)
+ }
+ }
+ }
+ }
+
+ if opts.MilestoneID > 0 {
+ sess.And("issue.milestone_id = ?", opts.MilestoneID)
+ }
+
+ if opts.AssigneeID > 0 {
+ applyAssigneeCondition(sess, opts.AssigneeID)
+ }
+
+ if opts.PosterID > 0 {
+ applyPosterCondition(sess, opts.PosterID)
+ }
+
+ if opts.MentionedID > 0 {
+ applyMentionedCondition(sess, opts.MentionedID)
+ }
+
+ if opts.ReviewRequestedID > 0 {
+ applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
+ }
+
+ switch opts.IsPull {
+ case util.OptionalBoolTrue:
+ sess.And("issue.is_pull=?", true)
+ case util.OptionalBoolFalse:
+ sess.And("issue.is_pull=?", false)
+ }
+
+ return sess
+ }
+
+ var err error
+ stats.OpenCount, err = countSession(opts, issueIDs).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return stats, err
+ }
+ stats.ClosedCount, err = countSession(opts, issueIDs).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ return stats, err
+}
+
+// UserIssueStatsOptions contains parameters accepted by GetUserIssueStats.
+type UserIssueStatsOptions struct {
+ UserID int64
+ RepoIDs []int64
+ FilterMode int
+ IsPull bool
+ IsClosed bool
+ IssueIDs []int64
+ IsArchived util.OptionalBool
+ LabelIDs []int64
+ RepoCond builder.Cond
+ Org *organization.Organization
+ Team *organization.Team
+}
+
+// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
+func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
+ var err error
+ stats := &IssueStats{}
+
+ cond := builder.NewCond()
+ cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull})
+ if len(opts.RepoIDs) > 0 {
+ cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs))
+ }
+ if len(opts.IssueIDs) > 0 {
+ cond = cond.And(builder.In("issue.id", opts.IssueIDs))
+ }
+ if opts.RepoCond != nil {
+ cond = cond.And(opts.RepoCond)
+ }
+
+ if opts.UserID > 0 {
+ cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.UserID, opts.Org, opts.Team, opts.IsPull))
+ }
+
+ sess := func(cond builder.Cond) *xorm.Session {
+ s := db.GetEngine(db.DefaultContext).Where(cond)
+ if len(opts.LabelIDs) > 0 {
+ s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id").
+ In("issue_label.label_id", opts.LabelIDs)
+ }
+ if opts.UserID > 0 || opts.IsArchived != util.OptionalBoolNone {
+ s.Join("INNER", "repository", "issue.repo_id = repository.id")
+ if opts.IsArchived != util.OptionalBoolNone {
+ s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()})
+ }
+ }
+ return s
+ }
+
+ switch opts.FilterMode {
+ case FilterModeAll, FilterModeYourRepositories:
+ stats.OpenCount, err = sess(cond).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ stats.ClosedCount, err = sess(cond).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ case FilterModeAssign:
+ stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ case FilterModeCreate:
+ stats.OpenCount, err = applyPosterCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ case FilterModeMention:
+ stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ case FilterModeReviewRequested:
+ stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", false).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).
+ And("issue.is_closed = ?", true).
+ Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed})
+ stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.UserID).Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ stats.CreateCount, err = applyPosterCondition(sess(cond), opts.UserID).Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.UserID).Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).Count(new(Issue))
+ if err != nil {
+ return nil, err
+ }
+
+ return stats, nil
+}
+
+// GetRepoIssueStats returns number of open and closed repository issues by given filter mode.
+func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) {
+ countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session {
+ sess := db.GetEngine(db.DefaultContext).
+ Where("is_closed = ?", isClosed).
+ And("is_pull = ?", isPull).
+ And("repo_id = ?", repoID)
+
+ return sess
+ }
+
+ openCountSession := countSession(false, isPull, repoID)
+ closedCountSession := countSession(true, isPull, repoID)
+
+ switch filterMode {
+ case FilterModeAssign:
+ applyAssigneeCondition(openCountSession, uid)
+ applyAssigneeCondition(closedCountSession, uid)
+ case FilterModeCreate:
+ applyPosterCondition(openCountSession, uid)
+ applyPosterCondition(closedCountSession, uid)
+ }
+
+ openResult, _ := openCountSession.Count(new(Issue))
+ closedResult, _ := closedCountSession.Count(new(Issue))
+
+ return openResult, closedResult
+}
+
+// SearchIssueIDsByKeyword search issues on database
+func SearchIssueIDsByKeyword(ctx context.Context, kw string, repoIDs []int64, limit, start int) (int64, []int64, error) {
+ repoCond := builder.In("repo_id", repoIDs)
+ subQuery := builder.Select("id").From("issue").Where(repoCond)
+ // SQLite's UPPER function only transforms ASCII letters.
+ if setting.Database.UseSQLite3 {
+ kw = util.ToUpperASCII(kw)
+ } else {
+ kw = strings.ToUpper(kw)
+ }
+ cond := builder.And(
+ repoCond,
+ builder.Or(
+ builder.Like{"UPPER(name)", kw},
+ builder.Like{"UPPER(content)", kw},
+ builder.In("id", builder.Select("issue_id").
+ From("comment").
+ Where(builder.And(
+ builder.Eq{"type": CommentTypeComment},
+ builder.In("issue_id", subQuery),
+ builder.Like{"UPPER(content)", kw},
+ )),
+ ),
+ ),
+ )
+
+ ids := make([]int64, 0, limit)
+ res := make([]struct {
+ ID int64
+ UpdatedUnix int64
+ }, 0, limit)
+ err := db.GetEngine(ctx).Distinct("id", "updated_unix").Table("issue").Where(cond).
+ OrderBy("`updated_unix` DESC").Limit(limit, start).
+ Find(&res)
+ if err != nil {
+ return 0, nil, err
+ }
+ for _, r := range res {
+ ids = append(ids, r.ID)
+ }
+
+ total, err := db.GetEngine(ctx).Distinct("id").Table("issue").Where(cond).Count()
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return total, ids, nil
+}
+
+// UpdateIssueByAPI updates all allowed fields of given issue.
+// If the issue status is changed a statusChangeComment is returned
+// similarly if the title is changed the titleChanged bool is set to true
+func UpdateIssueByAPI(issue *Issue, doer *user_model.User) (statusChangeComment *Comment, titleChanged bool, err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, false, err
+ }
+ defer committer.Close()
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return nil, false, fmt.Errorf("loadRepo: %v", err)
+ }
+
+ // Reload the issue
+ currentIssue, err := GetIssueByID(ctx, issue.ID)
+ if err != nil {
+ return nil, false, err
+ }
+
+ if _, err := db.GetEngine(ctx).ID(issue.ID).Cols(
+ "name", "content", "milestone_id", "priority",
+ "deadline_unix", "updated_unix", "is_locked").
+ Update(issue); err != nil {
+ return nil, false, err
+ }
+
+ titleChanged = currentIssue.Title != issue.Title
+ if titleChanged {
+ opts := &CreateCommentOptions{
+ Type: CommentTypeChangeTitle,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ OldTitle: currentIssue.Title,
+ NewTitle: issue.Title,
+ }
+ _, err := CreateCommentCtx(ctx, opts)
+ if err != nil {
+ return nil, false, fmt.Errorf("createComment: %v", err)
+ }
+ }
+
+ if currentIssue.IsClosed != issue.IsClosed {
+ statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false)
+ if err != nil {
+ return nil, false, err
+ }
+ }
+
+ if err := issue.AddCrossReferences(ctx, doer, true); err != nil {
+ return nil, false, err
+ }
+ return statusChangeComment, titleChanged, committer.Commit()
+}
+
+// UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it.
+func UpdateIssueDeadline(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
+ }
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // 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: %v", err)
+ }
+
+ return committer.Commit()
+}
+
+// DeleteInIssue delete records in beans with external key issue_id = ?
+func DeleteInIssue(ctx context.Context, issueID int64, beans ...interface{}) error {
+ e := db.GetEngine(ctx)
+ for _, bean := range beans {
+ if _, err := e.In("issue_id", issueID).Delete(bean); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// 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: %v", err)
+ }
+ if !util.IsInt64InSlice(issue.PosterID, userIDs) {
+ 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) (issueDeps []*DependencyInfo, err error) {
+ err = 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).
+ Find(&issueDeps)
+
+ for _, depInfo := range issueDeps {
+ depInfo.Issue.Repo = &depInfo.Repository
+ }
+
+ return issueDeps, 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 updateIssueClosedNum(ctx context.Context, issue *Issue) (err error) {
+ if issue.IsPull {
+ err = repo_model.StatsCorrectNumClosed(ctx, issue.RepoID, true, "num_closed_pulls")
+ } else {
+ err = repo_model.StatsCorrectNumClosed(ctx, issue.RepoID, false, "num_closed_issues")
+ }
+ return
+}
+
+// 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]: %v", issue.ID, err)
+ }
+ if err = UpdateIssueMentions(ctx, issue.ID, mentions); err != nil {
+ return nil, fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err)
+ }
+ return
+}
+
+// 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
+ }
+ if err = issue.LoadRepo(ctx); err != nil {
+ return
+ }
+
+ resolved := make(map[string]bool, 10)
+ var mentionTeams []string
+
+ if err := issue.Repo.GetOwner(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: %v", 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.AccessMode >= perm.AccessModeAdmin {
+ 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): %v", 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: %v", 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
+ }
+
+ 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: %v", 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.GetUserRepoPermission(ctx, issue.Repo, user)
+ if err != nil {
+ return nil, fmt.Errorf("GetUserRepoPermission [%d]: %v", user.ID, err)
+ }
+ if !perm.CanReadIssuesOrPulls(issue.IsPull) {
+ continue
+ }
+ users = append(users, user)
+ }
+
+ return
+}
+
+// UpdateIssuesMigrationsByType updates all migrated repositories' issues from gitServiceType to replace originalAuthorID to posterID
+func UpdateIssuesMigrationsByType(gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error {
+ _, err := db.GetEngine(db.DefaultContext).Table("issue").
+ Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
+ And("original_author_id = ?", originalAuthorID).
+ Update(map[string]interface{}{
+ "poster_id": posterID,
+ "original_author": "",
+ "original_author_id": 0,
+ })
+ return 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,
+ }),
+ )
+}
+
+// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID
+func UpdateReactionsMigrationsByType(gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error {
+ _, err := db.GetEngine(db.DefaultContext).Table("reaction").
+ Where("original_author_id = ?", originalAuthorID).
+ And(migratedIssueCond(gitServiceType)).
+ Update(map[string]interface{}{
+ "user_id": userID,
+ "original_author": "",
+ "original_author_id": 0,
+ })
+ return err
+}
+
+// DeleteIssuesByRepoID deletes issues by repositories id
+func DeleteIssuesByRepoID(ctx context.Context, repoID int64) (attachmentPaths []string, err error) {
+ deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID})
+
+ sess := db.GetEngine(ctx)
+ // Delete content histories
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&ContentHistory{}); err != nil {
+ return
+ }
+
+ // Delete comments and attachments
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&Comment{}); err != nil {
+ return
+ }
+
+ // Dependencies for issues in this repository
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&IssueDependency{}); err != nil {
+ return
+ }
+
+ // Delete dependencies for issues in other repositories
+ if _, err = sess.In("dependency_id", deleteCond).
+ Delete(&IssueDependency{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&IssueUser{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&Reaction{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&IssueWatch{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&Stopwatch{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&TrackedTime{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&project_model.ProjectIssue{}); err != nil {
+ return
+ }
+
+ if _, err = sess.In("dependent_issue_id", deleteCond).
+ Delete(&Comment{}); err != nil {
+ return
+ }
+
+ var attachments []*repo_model.Attachment
+ if err = sess.In("issue_id", deleteCond).
+ Find(&attachments); err != nil {
+ return
+ }
+
+ for j := range attachments {
+ attachmentPaths = append(attachmentPaths, attachments[j].RelativePath())
+ }
+
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&repo_model.Attachment{}); err != nil {
+ return
+ }
+
+ if _, err = db.DeleteByBean(ctx, &Issue{RepoID: repoID}); err != nil {
+ return
+ }
+
+ return
+}
+
+// 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 }
+
+// CountOrphanedIssues count issues without a repo
+func CountOrphanedIssues() (int64, error) {
+ return db.GetEngine(db.DefaultContext).Table("issue").
+ Join("LEFT", "repository", "issue.repo_id=repository.id").
+ Where(builder.IsNull{"repository.id"}).
+ Select("COUNT(`issue`.`id`)").
+ Count()
+}
+
+// DeleteOrphanedIssues delete issues without a repo
+func DeleteOrphanedIssues() error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ var ids []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"}).GroupBy("issue.repo_id").
+ Find(&ids); err != nil {
+ return err
+ }
+
+ var attachmentPaths []string
+ for i := range ids {
+ paths, err := DeleteIssuesByRepoID(ctx, ids[i])
+ if err != nil {
+ return err
+ }
+ attachmentPaths = append(attachmentPaths, paths...)
+ }
+
+ if err := committer.Commit(); err != nil {
+ return err
+ }
+ committer.Close()
+
+ // Remove issue attachment files.
+ for i := range attachmentPaths {
+ admin_model.RemoveAllWithNotice(db.DefaultContext, "Delete issue attachment", attachmentPaths[i])
+ }
+ return nil
+}
diff --git a/models/issues/issue_index.go b/models/issues/issue_index.go
new file mode 100644
index 0000000000..100e814317
--- /dev/null
+++ b/models/issues/issue_index.go
@@ -0,0 +1,32 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import "code.gitea.io/gitea/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(repoID int64) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := db.UpsertResourceIndex(ctx, "issue_index", repoID); err != nil {
+ return err
+ }
+
+ var max int64
+ if _, err := db.GetEngine(ctx).Select(" MAX(`index`)").Table("issue").Where("repo_id=?", repoID).Get(&max); err != nil {
+ return err
+ }
+
+ if _, err := db.GetEngine(ctx).Exec("UPDATE `issue_index` SET max_index=? WHERE group_id=?", max, repoID); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go
new file mode 100644
index 0000000000..20e9949b66
--- /dev/null
+++ b/models/issues/issue_list.go
@@ -0,0 +1,565 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/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 {
+ repoIDs := make(map[int64]struct{}, len(issues))
+ for _, issue := range issues {
+ if issue.Repo == nil {
+ repoIDs[issue.RepoID] = struct{}{}
+ }
+ if issue.PullRequest != nil && issue.PullRequest.HeadRepo == nil {
+ repoIDs[issue.PullRequest.HeadRepoID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(repoIDs)
+}
+
+func (issues IssueList) loadRepositories(ctx context.Context) ([]*repo_model.Repository, 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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ err := db.GetEngine(ctx).
+ In("id", repoIDs[:limit]).
+ Find(&repoMaps)
+ if err != nil {
+ return nil, fmt.Errorf("find repository: %v", 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
+}
+
+// LoadRepositories loads issues' all repositories
+func (issues IssueList) LoadRepositories() ([]*repo_model.Repository, error) {
+ return issues.loadRepositories(db.DefaultContext)
+}
+
+func (issues IssueList) getPosterIDs() []int64 {
+ posterIDs := make(map[int64]struct{}, len(issues))
+ for _, issue := range issues {
+ if _, ok := posterIDs[issue.PosterID]; !ok {
+ posterIDs[issue.PosterID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(posterIDs)
+}
+
+func (issues IssueList) loadPosters(ctx context.Context) error {
+ if len(issues) == 0 {
+ return nil
+ }
+
+ posterIDs := issues.getPosterIDs()
+ posterMaps := make(map[int64]*user_model.User, len(posterIDs))
+ left := len(posterIDs)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ err := db.GetEngine(ctx).
+ In("id", posterIDs[:limit]).
+ Find(&posterMaps)
+ if err != nil {
+ return err
+ }
+ left -= limit
+ posterIDs = posterIDs[limit:]
+ }
+
+ for _, issue := range issues {
+ if issue.PosterID <= 0 {
+ continue
+ }
+ var ok bool
+ if issue.Poster, ok = posterMaps[issue.PosterID]; !ok {
+ issue.Poster = user_model.NewGhostUser()
+ }
+ }
+ 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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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: %v", 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: %v", err1)
+ }
+ left -= limit
+ issueIDs = issueIDs[limit:]
+ }
+
+ for _, issue := range issues {
+ issue.Labels = issueLabels[issue.ID]
+ }
+ return nil
+}
+
+func (issues IssueList) getMilestoneIDs() []int64 {
+ ids := make(map[int64]struct{}, len(issues))
+ for _, issue := range issues {
+ if _, ok := ids[issue.MilestoneID]; !ok {
+ ids[issue.MilestoneID] = struct{}{}
+ }
+ }
+ return container.KeysInt64(ids)
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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]
+ }
+ 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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).Table("issue_assignees").
+ Join("INNER", "`user`", "`user`.id = `issue_assignees`.assignee_id").
+ In("`issue_assignees`.issue_id", issueIDs[:limit]).
+ 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: %v", 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: %v", err1)
+ }
+ left -= limit
+ issueIDs = issueIDs[limit:]
+ }
+
+ for _, issue := range issues {
+ issue.Assignees = assignees[issue.ID]
+ }
+ 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
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ 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: %v", err1)
+ }
+ return err
+ }
+ pullRequestMaps[pr.IssueID] = &pr
+ }
+ if err1 := rows.Close(); err1 != nil {
+ return fmt.Errorf("IssueList.loadPullRequests: Close: %v", err1)
+ }
+ left -= limit
+ issuesIDs = issuesIDs[limit:]
+ }
+
+ for _, issue := range issues {
+ issue.PullRequest = pullRequestMaps[issue.ID]
+ }
+ return nil
+}
+
+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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).Table("attachment").
+ Join("INNER", "issue", "issue.id = attachment.issue_id").
+ 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: %v", err1)
+ }
+ return err
+ }
+ attachments[attachment.IssueID] = append(attachments[attachment.IssueID], &attachment)
+ }
+ if err1 := rows.Close(); err1 != nil {
+ return fmt.Errorf("IssueList.loadAttachments: Close: %v", err1)
+ }
+ left -= limit
+ issuesIDs = issuesIDs[limit:]
+ }
+
+ for _, issue := range issues {
+ issue.Attachments = attachments[issue.ID]
+ }
+ 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 := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+ rows, err := db.GetEngine(ctx).Table("comment").
+ Join("INNER", "issue", "issue.id = comment.issue_id").
+ In("issue.id", issuesIDs[:limit]).
+ Where(cond).
+ 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: %v", err1)
+ }
+ return err
+ }
+ comments[comment.IssueID] = append(comments[comment.IssueID], &comment)
+ }
+ if err1 := rows.Close(); err1 != nil {
+ return fmt.Errorf("IssueList.loadComments: Close: %v", 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))
+
+ ids := make([]int64, 0, len(issues))
+ for _, issue := range issues {
+ if issue.Repo.IsTimetrackerEnabled() {
+ ids = append(ids, issue.ID)
+ }
+ }
+
+ left := len(ids)
+ for left > 0 {
+ limit := db.DefaultMaxInSize
+ if left < limit {
+ limit = left
+ }
+
+ // 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: %v", err1)
+ }
+ return err
+ }
+ trackedTimes[totalTime.IssueID] = totalTime.Time
+ }
+ if err1 := rows.Close(); err1 != nil {
+ return fmt.Errorf("IssueList.loadTotalTrackedTimes: Close: %v", err1)
+ }
+ left -= limit
+ ids = ids[limit:]
+ }
+
+ for _, issue := range issues {
+ issue.TotalTrackedTime = trackedTimes[issue.ID]
+ }
+ 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: %v", err)
+ }
+
+ if err := issues.loadPosters(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadPosters: %v", err)
+ }
+
+ if err := issues.loadLabels(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadLabels: %v", err)
+ }
+
+ if err := issues.loadMilestones(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadMilestones: %v", err)
+ }
+
+ if err := issues.loadAssignees(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadAssignees: %v", err)
+ }
+
+ if err := issues.loadPullRequests(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadPullRequests: %v", err)
+ }
+
+ if err := issues.loadTotalTrackedTimes(ctx); err != nil {
+ return fmt.Errorf("issue.loadAttributes: loadTotalTrackedTimes: %v", err)
+ }
+
+ return nil
+}
+
+// LoadAttributes loads attributes of the issues, except for attachments and
+// comments
+func (issues IssueList) LoadAttributes() error {
+ return issues.loadAttributes(db.DefaultContext)
+}
+
+// LoadAttachments loads attachments
+func (issues IssueList) LoadAttachments() error {
+ return issues.loadAttachments(db.DefaultContext)
+}
+
+// LoadComments loads comments
+func (issues IssueList) LoadComments() error {
+ return issues.loadComments(db.DefaultContext, builder.NewCond())
+}
+
+// LoadDiscussComments loads discuss comments
+func (issues IssueList) LoadDiscussComments() error {
+ return issues.loadComments(db.DefaultContext, builder.Eq{"comment.type": CommentTypeComment})
+}
+
+// LoadPullRequests loads pull requests
+func (issues IssueList) LoadPullRequests() error {
+ return issues.loadPullRequests(db.DefaultContext)
+}
+
+// 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
+}
diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go
new file mode 100644
index 0000000000..6b978f9ae6
--- /dev/null
+++ b/models/issues/issue_list_test.go
@@ -0,0 +1,73 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/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}).(*issues_model.Issue),
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}).(*issues_model.Issue),
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}).(*issues_model.Issue),
+ }
+
+ repos, err := issueList.LoadRepositories()
+ assert.NoError(t, err)
+ assert.Len(t, repos, 2)
+ for _, issue := range issueList {
+ assert.EqualValues(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}).(*issues_model.Issue),
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}).(*issues_model.Issue),
+ }
+
+ assert.NoError(t, issueList.LoadAttributes())
+ for _, issue := range issueList {
+ assert.EqualValues(t, issue.RepoID, issue.Repo.ID)
+ for _, label := range issue.Labels {
+ assert.EqualValues(t, issue.RepoID, label.RepoID)
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
+ }
+ if issue.PosterID > 0 {
+ assert.EqualValues(t, issue.PosterID, issue.Poster.ID)
+ }
+ if issue.AssigneeID > 0 {
+ assert.EqualValues(t, issue.AssigneeID, issue.Assignee.ID)
+ }
+ if issue.MilestoneID > 0 {
+ assert.EqualValues(t, issue.MilestoneID, issue.Milestone.ID)
+ }
+ if issue.IsPull {
+ assert.EqualValues(t, issue.ID, issue.PullRequest.IssueID)
+ }
+ for _, attachment := range issue.Attachments {
+ assert.EqualValues(t, issue.ID, attachment.IssueID)
+ }
+ for _, comment := range issue.Comments {
+ assert.EqualValues(t, issue.ID, comment.IssueID)
+ }
+ if issue.ID == int64(1) {
+ assert.Equal(t, int64(400), issue.TotalTrackedTime)
+ } else if issue.ID == int64(2) {
+ assert.Equal(t, int64(3682), issue.TotalTrackedTime)
+ }
+ }
+}
diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go
new file mode 100644
index 0000000000..7b52429ef7
--- /dev/null
+++ b/models/issues/issue_lock.go
@@ -0,0 +1,65 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// IssueLockOptions defines options for locking and/or unlocking an issue/PR
+type IssueLockOptions struct {
+ Doer *user_model.User
+ Issue *Issue
+ Reason string
+}
+
+// LockIssue locks an issue. This would limit commenting abilities to
+// users with write access to the repo
+func LockIssue(opts *IssueLockOptions) error {
+ return updateIssueLock(opts, true)
+}
+
+// UnlockIssue unlocks a previously locked issue.
+func UnlockIssue(opts *IssueLockOptions) error {
+ return updateIssueLock(opts, false)
+}
+
+func updateIssueLock(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
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ 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,
+ }
+ if _, err := CreateCommentCtx(ctx, opt); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
new file mode 100644
index 0000000000..5e0a337f7d
--- /dev/null
+++ b/models/issues/issue_project.go
@@ -0,0 +1,179 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ project_model "code.gitea.io/gitea/models/project"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// LoadProject load the project the issue was assigned to
+func (i *Issue) LoadProject() (err error) {
+ return i.loadProject(db.DefaultContext)
+}
+
+func (i *Issue) loadProject(ctx context.Context) (err error) {
+ if i.Project == nil {
+ var p project_model.Project
+ if _, err = db.GetEngine(ctx).Table("project").
+ Join("INNER", "project_issue", "project.id=project_issue.project_id").
+ Where("project_issue.issue_id = ?", i.ID).
+ Get(&p); err != nil {
+ return err
+ }
+ i.Project = &p
+ }
+ return
+}
+
+// ProjectID return project id if issue was assigned to one
+func (i *Issue) ProjectID() int64 {
+ return i.projectID(db.DefaultContext)
+}
+
+func (i *Issue) projectID(ctx context.Context) int64 {
+ var ip project_model.ProjectIssue
+ has, err := db.GetEngine(ctx).Where("issue_id=?", i.ID).Get(&ip)
+ if err != nil || !has {
+ return 0
+ }
+ return ip.ProjectID
+}
+
+// ProjectBoardID return project board id if issue was assigned to one
+func (i *Issue) ProjectBoardID() int64 {
+ return i.projectBoardID(db.DefaultContext)
+}
+
+func (i *Issue) projectBoardID(ctx context.Context) int64 {
+ var ip project_model.ProjectIssue
+ has, err := db.GetEngine(ctx).Where("issue_id=?", i.ID).Get(&ip)
+ if err != nil || !has {
+ return 0
+ }
+ return ip.ProjectBoardID
+}
+
+// LoadIssuesFromBoard load issues assigned to this board
+func LoadIssuesFromBoard(b *project_model.Board) (IssueList, error) {
+ issueList := make([]*Issue, 0, 10)
+
+ if b.ID != 0 {
+ issues, err := Issues(&IssuesOptions{
+ ProjectBoardID: b.ID,
+ ProjectID: b.ProjectID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ issueList = issues
+ }
+
+ if b.Default {
+ issues, err := Issues(&IssuesOptions{
+ ProjectBoardID: -1, // Issues without ProjectBoardID
+ ProjectID: b.ProjectID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ issueList = append(issueList, issues...)
+ }
+
+ if err := IssueList(issueList).LoadComments(); err != nil {
+ return nil, err
+ }
+
+ return issueList, nil
+}
+
+// LoadIssuesFromBoardList load issues assigned to the boards
+func LoadIssuesFromBoardList(bs project_model.BoardList) (map[int64]IssueList, error) {
+ issuesMap := make(map[int64]IssueList, len(bs))
+ for i := range bs {
+ il, err := LoadIssuesFromBoard(bs[i])
+ if err != nil {
+ return nil, err
+ }
+ issuesMap[bs[i].ID] = il
+ }
+ return issuesMap, nil
+}
+
+// ChangeProjectAssign changes the project associated with an issue
+func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
+ oldProjectID := issue.projectID(ctx)
+
+ if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
+ return err
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ if oldProjectID > 0 || newProjectID > 0 {
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeProject,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ OldProjectID: oldProjectID,
+ ProjectID: newProjectID,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return db.Insert(ctx, &project_model.ProjectIssue{
+ IssueID: issue.ID,
+ ProjectID: newProjectID,
+ })
+}
+
+// MoveIssueAcrossProjectBoards move a card from one board to another
+func MoveIssueAcrossProjectBoards(issue *Issue, board *project_model.Board) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ var pis project_model.ProjectIssue
+ has, err := sess.Where("issue_id=?", issue.ID).Get(&pis)
+ if err != nil {
+ return err
+ }
+
+ if !has {
+ return fmt.Errorf("issue has to be added to a project first")
+ }
+
+ pis.ProjectBoardID = board.ID
+ if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
new file mode 100644
index 0000000000..019e578da8
--- /dev/null
+++ b/models/issues/issue_test.go
@@ -0,0 +1,562 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/foreignreference"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "xorm.io/builder"
+)
+
+func TestIssue_ReplaceLabels(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ testSuccess := func(issueID int64, labelIDs []int64) {
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID}).(*issues_model.Issue)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}).(*repo_model.Repository)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}).(*user_model.User)
+
+ 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}).(*issues_model.Label)
+ }
+ assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer))
+ unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs))
+ for _, labelID := range labelIDs {
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
+ }
+ }
+
+ testSuccess(1, []int64{2})
+ testSuccess(1, []int64{1, 2})
+ testSuccess(1, []int64{})
+}
+
+func Test_GetIssueIDsByRepoID(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ ids, err := issues_model.GetIssueIDsByRepoID(db.DefaultContext, 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}).(*issues_model.Issue)
+ err := issue.LoadAttributes(db.DefaultContext)
+
+ assert.NoError(t, err)
+ assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/issues/1", issue.APIURL())
+}
+
+func TestGetIssuesByIDs(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ testSuccess := func(expectedIssueIDs, nonExistentIssueIDs []int64) {
+ issues, err := issues_model.GetIssuesByIDs(db.DefaultContext, append(expectedIssueIDs, nonExistentIssueIDs...))
+ 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})
+}
+
+func TestGetParticipantIDsByIssue(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ checkParticipants := func(issueID int64, userIDs []int) {
+ issue, err := issues_model.GetIssueByID(db.DefaultContext, issueID)
+ assert.NoError(t, err)
+ participants, err := issue.GetParticipantIDsByIssue(db.DefaultContext)
+ 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}).(*issues_model.Issue)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID}).(*user_model.User)
+ assert.NoError(t, issues_model.ClearIssueLabels(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{}).(*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(db.DefaultContext, issue, "name"))
+ then := time.Now().Unix()
+
+ updatedIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}).(*issues_model.Issue)
+ assert.EqualValues(t, newTitle, updatedIssue.Title)
+ assert.EqualValues(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",
+ ListOptions: db.ListOptions{
+ Page: 1,
+ PageSize: 4,
+ },
+ },
+ []int64{1, 2, 3, 5},
+ },
+ {
+ issues_model.IssuesOptions{
+ LabelIDs: []int64{1},
+ ListOptions: db.ListOptions{
+ Page: 1,
+ PageSize: 4,
+ },
+ },
+ []int64{2, 1},
+ },
+ {
+ issues_model.IssuesOptions{
+ LabelIDs: []int64{1, 2},
+ ListOptions: db.ListOptions{
+ Page: 1,
+ PageSize: 4,
+ },
+ },
+ []int64{}, // issues with **both** label 1 and 2, none of these issues matches, TODO: add more tests
+ },
+ } {
+ issues, err := issues_model.Issues(&test.Opts)
+ assert.NoError(t, err)
+ if assert.Len(t, issues, len(test.ExpectedIssueIDs)) {
+ for i, issue := range issues {
+ assert.EqualValues(t, test.ExpectedIssueIDs[i], issue.ID)
+ }
+ }
+ }
+}
+
+func TestGetUserIssueStats(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ for _, test := range []struct {
+ Opts issues_model.UserIssueStatsOptions
+ ExpectedIssueStats issues_model.IssueStats
+ }{
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ RepoIDs: []int64{1},
+ FilterMode: issues_model.FilterModeAll,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 6
+ AssignCount: 1, // 6
+ CreateCount: 1, // 6
+ OpenCount: 1, // 6
+ ClosedCount: 1, // 1
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ RepoIDs: []int64{1},
+ FilterMode: issues_model.FilterModeAll,
+ IsClosed: true,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 6
+ AssignCount: 0,
+ CreateCount: 0,
+ OpenCount: 1, // 6
+ ClosedCount: 1, // 1
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ FilterMode: issues_model.FilterModeAssign,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 6
+ AssignCount: 1, // 6
+ CreateCount: 1, // 6
+ OpenCount: 1, // 6
+ ClosedCount: 0,
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ FilterMode: issues_model.FilterModeCreate,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 6
+ AssignCount: 1, // 6
+ CreateCount: 1, // 6
+ OpenCount: 1, // 6
+ ClosedCount: 0,
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ FilterMode: issues_model.FilterModeMention,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 6
+ AssignCount: 1, // 6
+ CreateCount: 1, // 6
+ MentionCount: 0,
+ OpenCount: 0,
+ ClosedCount: 0,
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 1,
+ FilterMode: issues_model.FilterModeCreate,
+ IssueIDs: []int64{1},
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 1, // 1
+ AssignCount: 1, // 1
+ CreateCount: 1, // 1
+ OpenCount: 1, // 1
+ ClosedCount: 0,
+ },
+ },
+ {
+ issues_model.UserIssueStatsOptions{
+ UserID: 2,
+ Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}).(*organization.Organization),
+ Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}).(*organization.Team),
+ FilterMode: issues_model.FilterModeAll,
+ },
+ issues_model.IssueStats{
+ YourRepositoriesCount: 2,
+ AssignCount: 1,
+ CreateCount: 1,
+ OpenCount: 2,
+ },
+ },
+ } {
+ t.Run(fmt.Sprintf("%#v", test.Opts), func(t *testing.T) {
+ stats, err := issues_model.GetUserIssueStats(test.Opts)
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Equal(t, test.ExpectedIssueStats, *stats)
+ })
+ }
+}
+
+func TestIssue_loadTotalTimes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ ms, err := issues_model.GetIssueByID(db.DefaultContext, 2)
+ assert.NoError(t, err)
+ assert.NoError(t, ms.LoadTotalTimes(db.DefaultContext))
+ assert.Equal(t, int64(3682), ms.TotalTrackedTime)
+}
+
+func TestIssue_SearchIssueIDsByKeyword(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ total, ids, err := issues_model.SearchIssueIDsByKeyword(context.TODO(), "issue2", []int64{1}, 10, 0)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, total)
+ assert.EqualValues(t, []int64{2}, ids)
+
+ total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "first", []int64{1}, 10, 0)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, total)
+ assert.EqualValues(t, []int64{1}, ids)
+
+ total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "for", []int64{1}, 10, 0)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 5, total)
+ assert.ElementsMatch(t, []int64{1, 2, 3, 5, 11}, ids)
+
+ // issue1's comment id 2
+ total, ids, err = issues_model.SearchIssueIDsByKeyword(context.TODO(), "good", []int64{1}, 10, 0)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, total)
+ assert.EqualValues(t, []int64{1}, ids)
+}
+
+func TestGetRepoIDsForIssuesOptions(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+ for _, test := range []struct {
+ Opts issues_model.IssuesOptions
+ ExpectedRepoIDs []int64
+ }{
+ {
+ issues_model.IssuesOptions{
+ AssigneeID: 2,
+ },
+ []int64{3, 32},
+ },
+ {
+ issues_model.IssuesOptions{
+ RepoCond: builder.In("repo_id", 1, 2),
+ },
+ []int64{1, 2},
+ },
+ } {
+ repoIDs, err := issues_model.GetRepoIDsForIssuesOptions(&test.Opts, user)
+ assert.NoError(t, err)
+ if assert.Len(t, repoIDs, len(test.ExpectedRepoIDs)) {
+ for i, repoID := range repoIDs {
+ assert.EqualValues(t, test.ExpectedRepoIDs[i], repoID)
+ }
+ }
+ }
+}
+
+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}).(*repo_model.Repository)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+
+ issue := issues_model.Issue{
+ RepoID: repo.ID,
+ PosterID: user.ID,
+ Poster: user,
+ Title: title,
+ Content: content,
+ }
+ err := issues_model.NewIssue(repo, &issue, nil, nil)
+ assert.NoError(t, err)
+
+ has, err := db.GetEngine(db.DefaultContext).ID(issue.ID).Get(&newIssue)
+ assert.NoError(t, err)
+ assert.True(t, has)
+ assert.EqualValues(t, issue.Title, newIssue.Title)
+ assert.EqualValues(t, issue.Content, newIssue.Content)
+ if expectIndex > 0 {
+ assert.EqualValues(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.GetEngine(db.DefaultContext).ID(issue.ID).Delete(new(issues_model.Issue))
+ assert.NoError(t, err)
+
+ issue = testInsertIssue(t, `my issue2, this is my son's love \n \r \ `, "special issue's '' comments?", 7)
+ _, err = db.GetEngine(db.DefaultContext).ID(issue.ID).Delete(new(issues_model.Issue))
+ 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}).(*user_model.User)
+ r := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: o.ID, LowerName: repo}).(*repo_model.Repository)
+ issue := &issues_model.Issue{RepoID: r.ID}
+ d := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: doer}).(*user_model.User)
+ resolved, err := issues_model.ResolveIssueMentionsByVisibility(db.DefaultContext, issue, d, mentions)
+ assert.NoError(t, err)
+ ids := make([]int64, len(resolved))
+ for i, user := range resolved {
+ ids[i] = user.ID
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ assert.EqualValues(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("user17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
+ // Private repo, not a team member
+ testSuccess("user17", "big_test_private_4", "user20", []string{"user5"}, []int64{})
+ // Private repo, whole team
+ testSuccess("user17", "big_test_private_4", "user15", []string{"user17/owners"}, []int64{18})
+}
+
+func TestResourceIndex(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(i int) {
+ testInsertIssue(t, fmt.Sprintf("issue %d", i+1), "my issue", 0)
+ wg.Done()
+ }(i)
+ }
+ 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.
+
+ issueAmount := issues_model.MaxQueryParameters + 10
+
+ var wg sync.WaitGroup
+ for i := 0; i < issueAmount; i++ {
+ wg.Add(1)
+ go func(i int) {
+ testInsertIssue(t, fmt.Sprintf("Issue %d", i+1), "Bugs are nasty", 0)
+ wg.Done()
+ }(i)
+ }
+ wg.Wait()
+
+ // Now we will get all issueID's that match the "Bugs are nasty" query.
+ total, ids, err := issues_model.SearchIssueIDsByKeyword(context.TODO(), "Bugs are nasty", []int64{1}, issueAmount, 0)
+
+ // 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(&issues_model.IssueStatsOptions{
+ RepoID: 1,
+ IssueIDs: ids,
+ })
+
+ // Now check the values.
+ assert.NoError(t, err)
+ assert.EqualValues(t, issueStats.OpenCount, issueAmount)
+}
+
+func TestIssueForeignReference(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}).(*issues_model.Issue)
+ assert.NotEqualValues(t, issue.Index, issue.ID) // make sure they are different to avoid false positive
+
+ // it is fine for an issue to not have a foreign reference
+ err := issue.LoadAttributes(db.DefaultContext)
+ assert.NoError(t, err)
+ assert.Nil(t, issue.ForeignReference)
+
+ var foreignIndex int64 = 12345
+ _, err = issues_model.GetIssueByForeignIndex(context.Background(), issue.RepoID, foreignIndex)
+ assert.True(t, foreignreference.IsErrLocalIndexNotExist(err))
+
+ err = db.Insert(db.DefaultContext, &foreignreference.ForeignReference{
+ LocalIndex: issue.Index,
+ ForeignIndex: strconv.FormatInt(foreignIndex, 10),
+ RepoID: issue.RepoID,
+ Type: foreignreference.TypeIssue,
+ })
+ assert.NoError(t, err)
+
+ err = issue.LoadAttributes(db.DefaultContext)
+ assert.NoError(t, err)
+
+ assert.EqualValues(t, issue.ForeignReference.ForeignIndex, strconv.FormatInt(foreignIndex, 10))
+
+ found, err := issues_model.GetIssueByForeignIndex(context.Background(), issue.RepoID, foreignIndex)
+ assert.NoError(t, err)
+ assert.EqualValues(t, found.Index, issue.Index)
+}
+
+func TestMilestoneList_LoadTotalTrackedTimes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ miles := issues_model.MilestoneList{
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}).(*issues_model.Milestone),
+ }
+
+ assert.NoError(t, miles.LoadTotalTrackedTimes())
+
+ 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}).(*issues_model.Milestone)
+
+ assert.NoError(t, milestone.LoadTotalTrackedTime())
+
+ assert.Equal(t, int64(3682), milestone.TotalTrackedTime)
+}
+
+func TestCountIssues(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ count, err := issues_model.CountIssues(&issues_model.IssuesOptions{})
+ assert.NoError(t, err)
+ assert.EqualValues(t, 17, count)
+}
diff --git a/models/issues/issue_user.go b/models/issues/issue_user.go
new file mode 100644
index 0000000000..f5d22589af
--- /dev/null
+++ b/models/issues/issue_user.go
@@ -0,0 +1,87 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+)
+
+// IssueUser represents an issue-user relation.
+type IssueUser struct {
+ ID int64 `xorm:"pk autoincr"`
+ UID int64 `xorm:"INDEX"` // User ID.
+ IssueID int64
+ 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: %v", 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(uid, issueID int64) error {
+ _, err := db.GetEngine(db.DefaultContext).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
+}
diff --git a/models/issues/issue_user_test.go b/models/issues/issue_user_test.go
new file mode 100644
index 0000000000..33e9f98ecc
--- /dev/null
+++ b/models/issues/issue_user_test.go
@@ -0,0 +1,62 @@
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_NewIssueUsers(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository)
+ newIssue := &issues_model.Issue{
+ RepoID: repo.ID,
+ PosterID: 4,
+ Index: 6,
+ Title: "newTestIssueTitle",
+ Content: "newTestIssueContent",
+ }
+
+ // artificially insert new issue
+ unittest.AssertSuccessfulInsert(t, newIssue)
+
+ assert.NoError(t, issues_model.NewIssueUsers(db.DefaultContext, 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}).(*issues_model.Issue)
+
+ assert.NoError(t, issues_model.UpdateIssueUserByRead(4, issue.ID))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: 4}, "is_read=1")
+
+ assert.NoError(t, issues_model.UpdateIssueUserByRead(4, issue.ID))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: 4}, "is_read=1")
+
+ assert.NoError(t, issues_model.UpdateIssueUserByRead(unittest.NonexistentID, unittest.NonexistentID))
+}
+
+func TestUpdateIssueUsersByMentions(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}).(*issues_model.Issue)
+
+ uids := []int64{2, 5}
+ assert.NoError(t, issues_model.UpdateIssueUsersByMentions(db.DefaultContext, issue.ID, uids))
+ for _, uid := range uids {
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueUser{IssueID: issue.ID, UID: uid}, "is_mentioned=1")
+ }
+}
diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go
new file mode 100644
index 0000000000..bf907aa8fd
--- /dev/null
+++ b/models/issues/issue_watch.go
@@ -0,0 +1,135 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/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(userID, issueID int64, isWatching bool) error {
+ iw, exists, err := GetIssueWatch(db.DefaultContext, userID, issueID)
+ if err != nil {
+ return err
+ }
+
+ if !exists {
+ iw = &IssueWatch{
+ UserID: userID,
+ IssueID: issueID,
+ IsWatching: isWatching,
+ }
+
+ if _, err := db.GetEngine(db.DefaultContext).Insert(iw); err != nil {
+ return err
+ }
+ } else {
+ iw.IsWatching = isWatching
+
+ if _, err := db.GetEngine(db.DefaultContext).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
+}
+
+// CheckIssueWatch check if an user is watching an issue
+// it takes participants and repo watch into account
+func CheckIssueWatch(user *user_model.User, issue *Issue) (bool, error) {
+ iw, exist, err := GetIssueWatch(db.DefaultContext, user.ID, issue.ID)
+ if err != nil {
+ return false, err
+ }
+ if exist {
+ return iw.IsWatching, nil
+ }
+ w, err := repo_model.GetWatch(db.DefaultContext, user.ID, issue.RepoID)
+ if err != nil {
+ return false, err
+ }
+ return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(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 {
+ sess = 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
+}
diff --git a/models/issues/issue_watch_test.go b/models/issues/issue_watch_test.go
new file mode 100644
index 0000000000..c6b6416d9b
--- /dev/null
+++ b/models/issues/issue_watch_test.go
@@ -0,0 +1,68 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCreateOrUpdateIssueWatch(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(3, 1, true))
+ iw := unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 3, IssueID: 1}).(*issues_model.IssueWatch)
+ assert.True(t, iw.IsWatching)
+
+ assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(1, 1, false))
+ iw = unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 1, IssueID: 1}).(*issues_model.IssueWatch)
+ assert.False(t, iw.IsWatching)
+}
+
+func TestGetIssueWatch(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ _, exists, err := issues_model.GetIssueWatch(db.DefaultContext, 9, 1)
+ assert.True(t, exists)
+ assert.NoError(t, err)
+
+ iw, exists, err := issues_model.GetIssueWatch(db.DefaultContext, 2, 2)
+ assert.True(t, exists)
+ assert.NoError(t, err)
+ assert.False(t, iw.IsWatching)
+
+ _, exists, err = issues_model.GetIssueWatch(db.DefaultContext, 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(db.DefaultContext, 1, db.ListOptions{})
+ assert.NoError(t, err)
+ // Watcher is inactive, thus 0
+ assert.Len(t, iws, 0)
+
+ iws, err = issues_model.GetIssueWatchers(db.DefaultContext, 2, db.ListOptions{})
+ assert.NoError(t, err)
+ // Watcher is explicit not watching
+ assert.Len(t, iws, 0)
+
+ iws, err = issues_model.GetIssueWatchers(db.DefaultContext, 5, db.ListOptions{})
+ assert.NoError(t, err)
+ // Issue has no Watchers
+ assert.Len(t, iws, 0)
+
+ iws, err = issues_model.GetIssueWatchers(db.DefaultContext, 7, db.ListOptions{})
+ assert.NoError(t, err)
+ // Issue has one watcher
+ assert.Len(t, iws, 1)
+}
diff --git a/models/issues/issue_xref.go b/models/issues/issue_xref.go
new file mode 100644
index 0000000000..f4380a02ec
--- /dev/null
+++ b/models/issues/issue_xref.go
@@ -0,0 +1,357 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/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 := CreateCommentCtx(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.GetRepositoryByOwnerAndNameCtx(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.GetUserRepoPermission(stdCtx, refIssue.Repo, ctx.Doer)
+ if err != nil {
+ return nil, references.XRefActionNone, err
+ }
+ if !perm.CanReadIssuesOrPulls(refIssue.IsPull) {
+ 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 (comment *Comment) AddCrossReferences(stdCtx context.Context, doer *user_model.User, removeOld bool) error {
+ if comment.Type != CommentTypeCode && comment.Type != CommentTypeComment {
+ return nil
+ }
+ if err := comment.LoadIssueCtx(stdCtx); err != nil {
+ return err
+ }
+ ctx := &crossReferencesContext{
+ Type: CommentTypeCommentRef,
+ Doer: doer,
+ OrigIssue: comment.Issue,
+ OrigComment: comment,
+ RemoveOld: removeOld,
+ }
+ return comment.Issue.createCrossReferences(stdCtx, ctx, "", comment.Content)
+}
+
+func (comment *Comment) neuterCrossReferences(ctx context.Context) error {
+ return neuterCrossReferences(ctx, comment.IssueID, comment.ID)
+}
+
+// LoadRefComment loads comment that created this reference from database
+func (comment *Comment) LoadRefComment() (err error) {
+ if comment.RefComment != nil {
+ return nil
+ }
+ comment.RefComment, err = GetCommentByID(db.DefaultContext, comment.RefCommentID)
+ return
+}
+
+// LoadRefIssue loads comment that created this reference from database
+func (comment *Comment) LoadRefIssue() (err error) {
+ if comment.RefIssue != nil {
+ return nil
+ }
+ comment.RefIssue, err = GetIssueByID(db.DefaultContext, comment.RefIssueID)
+ if err == nil {
+ err = comment.RefIssue.LoadRepo(db.DefaultContext)
+ }
+ return
+}
+
+// CommentTypeIsRef returns true if CommentType is a reference from another issue
+func CommentTypeIsRef(t CommentType) bool {
+ return t == CommentTypeCommentRef || t == CommentTypePullRef || t == CommentTypeIssueRef
+}
+
+// RefCommentHTMLURL returns the HTML URL for the comment that created this reference
+func (comment *Comment) RefCommentHTMLURL() string {
+ // Edge case for when the reference is inside the title or the description of the referring issue
+ if comment.RefCommentID == 0 {
+ return comment.RefIssueHTMLURL()
+ }
+ if err := comment.LoadRefComment(); err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadRefComment(%d): %v", comment.RefCommentID, err)
+ return ""
+ }
+ return comment.RefComment.HTMLURL()
+}
+
+// RefIssueHTMLURL returns the HTML URL of the issue where this reference was created
+func (comment *Comment) RefIssueHTMLURL() string {
+ if err := comment.LoadRefIssue(); err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadRefIssue(%d): %v", comment.RefCommentID, err)
+ return ""
+ }
+ return comment.RefIssue.HTMLURL()
+}
+
+// RefIssueTitle returns the title of the issue where this reference was created
+func (comment *Comment) RefIssueTitle() string {
+ if err := comment.LoadRefIssue(); err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadRefIssue(%d): %v", comment.RefCommentID, err)
+ return ""
+ }
+ return comment.RefIssue.Title
+}
+
+// RefIssueIdent returns the user friendly identity (e.g. "#1234") of the issue where this reference was created
+func (comment *Comment) RefIssueIdent() string {
+ if err := comment.LoadRefIssue(); err != nil { // Silently dropping errors :unamused:
+ log.Error("LoadRefIssue(%d): %v", comment.RefCommentID, err)
+ return ""
+ }
+ // FIXME: check this name for cross-repository references (#7901 if it gets merged)
+ return fmt.Sprintf("#%d", comment.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: %v", 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
+}
diff --git a/models/issues/issue_xref_test.go b/models/issues/issue_xref_test.go
new file mode 100644
index 0000000000..6bb19d5328
--- /dev/null
+++ b/models/issues/issue_xref_test.go
@@ -0,0 +1,184 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "fmt"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/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}).(*issues_model.Comment)
+ 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, 1, 2, pr.ID, content)
+ ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: c.ID}).(*issues_model.Comment)
+ 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}).(*issues_model.Comment)
+ 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 user3/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}).(*issues_model.Comment)
+ 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 user3/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}).(*issues_model.Comment)
+ assert.Equal(t, issues_model.CommentTypeIssueRef, ref.Type)
+ assert.Equal(t, references.XRefActionNone, ref.RefAction)
+
+ d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+ i.Title = "title2, no mentions"
+ assert.NoError(t, issues_model.ChangeIssueTitle(i, d, title))
+
+ ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*issues_model.Comment)
+ 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}).(*user_model.User)
+
+ 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.ChangeIssueStatus(db.DefaultContext, i3, d, true)
+ 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}).(*issues_model.Comment)
+
+ c1 := testCreateComment(t, 1, 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}).(*issues_model.Comment)
+
+ // Must be ignored
+ c2 := testCreateComment(t, 1, 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, 1, 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, 1, 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}).(*issues_model.Comment)
+
+ refs, err := pr.ResolveCrossReferences(db.DefaultContext)
+ 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}).(*repo_model.Repository)
+ d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer}).(*user_model.User)
+
+ idx, err := db.GetNextResourceIndex("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,
+ }
+
+ ctx, committer, err := db.TxContext()
+ assert.NoError(t, err)
+ defer committer.Close()
+ 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}).(*repo_model.Repository)
+ d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer}).(*user_model.User)
+ 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(db.DefaultContext, r, i, nil, nil, pr))
+ pr.Issue = i
+ return pr
+}
+
+func testCreateComment(t *testing.T, repo, doer, issue int64, content string) *issues_model.Comment {
+ d := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doer}).(*user_model.User)
+ i := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue}).(*issues_model.Issue)
+ c := &issues_model.Comment{Type: issues_model.CommentTypeComment, PosterID: doer, Poster: d, IssueID: issue, Issue: i, Content: content}
+
+ ctx, committer, err := db.TxContext()
+ 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
+}
diff --git a/models/issues/label.go b/models/issues/label.go
new file mode 100644
index 0000000000..98e2e43961
--- /dev/null
+++ b/models/issues/label.go
@@ -0,0 +1,836 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "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)
+}
+
+// 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)
+}
+
+// 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)
+}
+
+// LabelColorPattern is a regexp witch can validate LabelColor
+var LabelColorPattern = regexp.MustCompile("^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
+
+// 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
+ 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:"-"`
+}
+
+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 (label *Label) CalOpenIssues() {
+ label.NumOpenIssues = label.NumIssues - label.NumClosedIssues
+}
+
+// CalOpenOrgIssues calculates the open issues of a label for a specific repo
+func (label *Label) CalOpenOrgIssues(repoID, labelID int64) {
+ counts, _ := CountIssuesByRepo(&IssuesOptions{
+ RepoID: repoID,
+ LabelIDs: []int64{labelID},
+ })
+
+ for _, count := range counts {
+ label.NumOpenRepoIssues += count
+ }
+}
+
+// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
+func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) {
+ var labelQuerySlice []string
+ labelSelected := false
+ labelID := strconv.FormatInt(label.ID, 10)
+ for _, s := range currentSelectedLabels {
+ if s == label.ID {
+ labelSelected = true
+ } else if -s == label.ID {
+ labelSelected = true
+ label.IsExcluded = true
+ } else if s != 0 {
+ labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+ }
+ }
+ if !labelSelected {
+ labelQuerySlice = append(labelQuerySlice, labelID)
+ }
+ label.IsSelected = labelSelected
+ label.QueryString = strings.Join(labelQuerySlice, ",")
+}
+
+// BelongsToOrg returns true if label is an organization label
+func (label *Label) BelongsToOrg() bool {
+ return label.OrgID > 0
+}
+
+// BelongsToRepo returns true if label is a repository label
+func (label *Label) BelongsToRepo() bool {
+ return label.RepoID > 0
+}
+
+// SrgbToLinear converts a component of an sRGB color to its linear intensity
+// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ)
+func SrgbToLinear(color uint8) float64 {
+ flt := float64(color) / 255
+ if flt <= 0.04045 {
+ return flt / 12.92
+ }
+ return math.Pow((flt+0.055)/1.055, 2.4)
+}
+
+// Luminance returns the luminance of an sRGB color
+func Luminance(color uint32) float64 {
+ r := SrgbToLinear(uint8(0xFF & (color >> 16)))
+ g := SrgbToLinear(uint8(0xFF & (color >> 8)))
+ b := SrgbToLinear(uint8(0xFF & color))
+
+ // luminance ratios for sRGB
+ return 0.2126*r + 0.7152*g + 0.0722*b
+}
+
+// LuminanceThreshold is the luminance at which white and black appear to have the same contrast
+// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05
+// i.e. math.Sqrt(1.05*0.05) - 0.05
+const LuminanceThreshold float64 = 0.179
+
+// ForegroundColor calculates the text color for labels based
+// on their background color.
+func (label *Label) ForegroundColor() template.CSS {
+ if strings.HasPrefix(label.Color, "#") {
+ if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil {
+ // NOTE: see web_src/js/components/ContextPopup.vue for similar implementation
+ luminance := Luminance(uint32(color))
+
+ // prefer white or black based upon contrast
+ if luminance < LuminanceThreshold {
+ return template.CSS("#fff")
+ }
+ return template.CSS("#000")
+ }
+ }
+
+ // default to black
+ return template.CSS("#000")
+}
+
+// NewLabel creates a new label
+func NewLabel(ctx context.Context, label *Label) error {
+ if !LabelColorPattern.MatchString(label.Color) {
+ return fmt.Errorf("bad color code: %s", label.Color)
+ }
+
+ // normalize case
+ label.Color = strings.ToLower(label.Color)
+
+ // add leading hash
+ if label.Color[0] != '#' {
+ label.Color = "#" + label.Color
+ }
+
+ // convert 3-character shorthand into 6-character version
+ if len(label.Color) == 4 {
+ r := label.Color[1]
+ g := label.Color[2]
+ b := label.Color[3]
+ label.Color = fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b)
+ }
+
+ return db.Insert(ctx, label)
+}
+
+// NewLabels creates new labels
+func NewLabels(labels ...*Label) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ for _, label := range labels {
+ if !LabelColorPattern.MatchString(label.Color) {
+ return fmt.Errorf("bad color code: %s", label.Color)
+ }
+ if err := db.Insert(ctx, label); err != nil {
+ return err
+ }
+ }
+ return committer.Commit()
+}
+
+// UpdateLabel updates label information.
+func UpdateLabel(l *Label) error {
+ if !LabelColorPattern.MatchString(l.Color) {
+ return fmt.Errorf("bad color code: %s", l.Color)
+ }
+ return updateLabelCols(db.DefaultContext, l, "name", "description", "color")
+}
+
+// DeleteLabel delete a label
+func DeleteLabel(id, labelID int64) error {
+ label, err := GetLabelByID(db.DefaultContext, labelID)
+ if err != nil {
+ if IsErrLabelNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ sess := db.GetEngine(ctx)
+
+ if label.BelongsToOrg() && label.OrgID != id {
+ return nil
+ }
+ if label.BelongsToRepo() && label.RepoID != id {
+ return nil
+ }
+
+ if _, err = sess.ID(labelID).Delete(new(Label)); err != nil {
+ return err
+ } else if _, err = sess.
+ Where("label_id = ?", labelID).
+ Delete(new(IssueLabel)); err != nil {
+ return err
+ }
+
+ // delete comments about now deleted label_id
+ if _, err = sess.Where("label_id = ?", labelID).Cols("label_id").Delete(&Comment{}); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// 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(labelIDs []int64) ([]*Label, error) {
+ labels := make([]*Label, 0, len(labelIDs))
+ return labels, db.GetEngine(db.DefaultContext).Table("label").
+ In("id", labelIDs).
+ Asc("name").
+ Cols("id", "repo_id", "org_id").
+ 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 := &Label{
+ Name: labelName,
+ RepoID: repoID,
+ }
+ has, err := db.GetByBean(ctx, l)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrRepoLabelNotExist{0, l.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 := &Label{
+ ID: labelID,
+ RepoID: repoID,
+ }
+ has, err := db.GetByBean(ctx, l)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrRepoLabelNotExist{l.ID, l.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(repoID int64, labelNames []string) ([]int64, error) {
+ labelIDs := make([]int64, 0, len(labelNames))
+ return labelIDs, db.GetEngine(db.DefaultContext).Table("label").
+ Where("repo_id = ?", repoID).
+ 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(repoID int64, labelIDs []int64) ([]*Label, error) {
+ labels := make([]*Label, 0, len(labelIDs))
+ return labels, db.GetEngine(db.DefaultContext).
+ 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 {
+ sess = db.SetSessionPagination(sess, &listOptions)
+ }
+
+ return labels, sess.Find(&labels)
+}
+
+// CountLabelsByRepoID count number of all labels that belong to given repository by ID.
+func CountLabelsByRepoID(repoID int64) (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID).Count(&Label{})
+}
+
+// ________
+// \_____ \_______ ____
+// / | \_ __ \/ ___\
+// / | \ | \/ /_/ >
+// \_______ /__| \___ /
+// \/ /_____/
+
+// GetLabelInOrgByName returns a label by name in given organization.
+func GetLabelInOrgByName(ctx context.Context, orgID int64, labelName string) (*Label, error) {
+ if len(labelName) == 0 || orgID <= 0 {
+ return nil, ErrOrgLabelNotExist{0, orgID}
+ }
+
+ l := &Label{
+ Name: labelName,
+ OrgID: orgID,
+ }
+ has, err := db.GetByBean(ctx, l)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrOrgLabelNotExist{0, l.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 := &Label{
+ ID: labelID,
+ OrgID: orgID,
+ }
+ has, err := db.GetByBean(ctx, l)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrOrgLabelNotExist{l.ID, l.OrgID}
+ }
+ return l, nil
+}
+
+// GetLabelIDsInOrgByNames returns a list of labelIDs by names in a given
+// organization.
+func GetLabelIDsInOrgByNames(orgID int64, labelNames []string) ([]int64, error) {
+ if orgID <= 0 {
+ return nil, ErrOrgLabelNotExist{0, orgID}
+ }
+ labelIDs := make([]int64, 0, len(labelNames))
+
+ return labelIDs, db.GetEngine(db.DefaultContext).Table("label").
+ Where("org_id = ?", orgID).
+ In("name", labelNames).
+ Asc("name").
+ Cols("id").
+ Find(&labelIDs)
+}
+
+// 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(orgID int64, labelIDs []int64) ([]*Label, error) {
+ labels := make([]*Label, 0, len(labelIDs))
+ return labels, db.GetEngine(db.DefaultContext).
+ 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 {
+ sess = db.SetSessionPagination(sess, &listOptions)
+ }
+
+ return labels, sess.Find(&labels)
+}
+
+// CountLabelsByOrgID count all labels that belong to given organization by ID.
+func CountLabelsByOrgID(orgID int64) (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where("org_id = ?", orgID).Count(&Label{})
+}
+
+// .___
+// | | ______ ________ __ ____
+// | |/ ___// ___/ | \_/ __ \
+// | |\___ \ \___ \| | /\ ___/
+// |___/____ >____ >____/ \___ |
+// \/ \/ \/
+
+// GetLabelsByIssueID returns all labels that belong to given issue by ID.
+func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) {
+ var labels []*Label
+ return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID).
+ Join("LEFT", "issue_label", "issue_label.label_id = label.id").
+ Asc("label.name").
+ Find(&labels)
+}
+
+func updateLabelCols(ctx context.Context, l *Label, cols ...string) error {
+ _, err := db.GetEngine(ctx).ID(l.ID).
+ SetExpr("num_issues",
+ 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
+}
+
+// .___ .____ ___. .__
+// | | ______ ________ __ ____ | | _____ \_ |__ ____ | |
+// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| |
+// | |\___ \ \___ \| | /\ ___/| |___ / __ \| \_\ \ ___/| |__
+// |___/____ >____ >____/ \___ >_______ (____ /___ /\___ >____/
+// \/ \/ \/ \/ \/ \/ \/
+
+// IssueLabel represents an issue-label relation.
+type IssueLabel struct {
+ ID int64 `xorm:"pk autoincr"`
+ IssueID int64 `xorm:"UNIQUE(s)"`
+ LabelID int64 `xorm:"UNIQUE(s)"`
+}
+
+// HasIssueLabel returns true if issue has been labeled.
+func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool {
+ has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel))
+ return has
+}
+
+// newIssueLabel this function creates a new label it does not check if the label is valid for the issue
+// YOU MUST CHECK THIS BEFORE THIS FUNCTION
+func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
+ if err = db.Insert(ctx, &IssueLabel{
+ IssueID: issue.ID,
+ LabelID: label.ID,
+ }); err != nil {
+ return err
+ }
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return
+ }
+
+ opts := &CreateCommentOptions{
+ Type: CommentTypeLabel,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ Label: label,
+ Content: "1",
+ }
+ if _, err = CreateCommentCtx(ctx, opts); err != nil {
+ return err
+ }
+
+ return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
+}
+
+// NewIssueLabel creates a new issue-label relation.
+func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
+ if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
+ return nil
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ // Do NOT add invalid labels
+ if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID {
+ return nil
+ }
+
+ if err = newIssueLabel(ctx, issue, label, doer); err != nil {
+ return err
+ }
+
+ issue.Labels = nil
+ if err = issue.LoadLabels(ctx); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue
+func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) {
+ if err = issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+ for _, label := range labels {
+ // Don't add already present labels and invalid labels
+ if HasIssueLabel(ctx, issue.ID, label.ID) ||
+ (label.RepoID != issue.RepoID && label.OrgID != issue.Repo.OwnerID) {
+ continue
+ }
+
+ if err = newIssueLabel(ctx, issue, label, doer); err != nil {
+ return fmt.Errorf("newIssueLabel: %v", err)
+ }
+ }
+
+ return nil
+}
+
+// NewIssueLabels creates a list of issue-label relations.
+func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err = newIssueLabels(ctx, issue, labels, doer); err != nil {
+ return err
+ }
+
+ issue.Labels = nil
+ if err = issue.LoadLabels(ctx); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
+ if count, err := db.DeleteByBean(ctx, &IssueLabel{
+ IssueID: issue.ID,
+ LabelID: label.ID,
+ }); err != nil {
+ return err
+ } else if count == 0 {
+ return nil
+ }
+
+ if err = issue.LoadRepo(ctx); err != nil {
+ return
+ }
+
+ opts := &CreateCommentOptions{
+ Type: CommentTypeLabel,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ Label: label,
+ }
+ if _, err = CreateCommentCtx(ctx, opts); err != nil {
+ return err
+ }
+
+ return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
+}
+
+// DeleteIssueLabel deletes issue-label relation.
+func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error {
+ if err := deleteIssueLabel(ctx, issue, label, doer); err != nil {
+ return err
+ }
+
+ issue.Labels = nil
+ return issue.LoadLabels(ctx)
+}
+
+// DeleteLabelsByRepoID deletes labels of some repository
+func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error {
+ deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID})
+
+ if _, err := db.GetEngine(ctx).In("label_id", deleteCond).
+ Delete(&IssueLabel{}); err != nil {
+ return err
+ }
+
+ _, err := db.DeleteByBean(ctx, &Label{RepoID: repoID})
+ return err
+}
+
+// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore
+func CountOrphanedLabels() (int64, error) {
+ noref, err := db.GetEngine(db.DefaultContext).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count("label.id")
+ if err != nil {
+ return 0, err
+ }
+
+ norepo, err := db.GetEngine(db.DefaultContext).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(db.DefaultContext).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() error {
+ // delete labels with no reference
+ if _, err := db.GetEngine(db.DefaultContext).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(db.DefaultContext).
+ 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(db.DefaultContext).
+ 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() (int64, error) {
+ return db.GetEngine(db.DefaultContext).Table("issue_label").
+ NotIn("label_id", builder.Select("id").From("label")).
+ Count()
+}
+
+// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore
+func DeleteOrphanedIssueLabels() error {
+ _, err := db.GetEngine(db.DefaultContext).
+ NotIn("label_id", builder.Select("id").From("label")).
+ Delete(IssueLabel{})
+ return err
+}
+
+// CountIssueLabelWithOutsideLabels count label comments with outside label
+func CountIssueLabelWithOutsideLabels() (int64, error) {
+ return db.GetEngine(db.DefaultContext).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() (int64, error) {
+ res, err := db.GetEngine(db.DefaultContext).Exec(`DELETE FROM issue_label WHERE issue_label.id IN (
+ SELECT il_too.id FROM (
+ SELECT il_too_too.id
+ FROM issue_label AS il_too_too
+ INNER JOIN label ON il_too_too.label_id = label.id
+ INNER JOIN issue on issue.id = il_too_too.issue_id
+ INNER JOIN repository on repository.id = issue.repo_id
+ WHERE
+ (label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)
+ ) AS il_too )`)
+ if err != nil {
+ return 0, err
+ }
+
+ return res.RowsAffected()
+}
diff --git a/models/issues/label_test.go b/models/issues/label_test.go
new file mode 100644
index 0000000000..33f114b5fe
--- /dev/null
+++ b/models/issues/label_test.go
@@ -0,0 +1,395 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "html/template"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TODO TestGetLabelTemplateFile
+
+func TestLabel_CalOpenIssues(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}).(*issues_model.Label)
+ label.CalOpenIssues()
+ assert.EqualValues(t, 2, label.NumOpenIssues)
+}
+
+func TestLabel_ForegroundColor(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}).(*issues_model.Label)
+ assert.Equal(t, template.CSS("#000"), label.ForegroundColor())
+
+ label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}).(*issues_model.Label)
+ assert.Equal(t, template.CSS("#fff"), label.ForegroundColor())
+}
+
+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(db.DefaultContext, &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: ""}))
+ assert.Error(t, issues_model.NewLabel(db.DefaultContext, &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "#45G"}))
+ assert.Error(t, issues_model.NewLabel(db.DefaultContext, &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "#12345G"}))
+ assert.Error(t, issues_model.NewLabel(db.DefaultContext, &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "45G"}))
+ assert.Error(t, issues_model.NewLabel(db.DefaultContext, &issues_model.Label{RepoID: 3, Name: "invalid Color", Color: "12345G"}))
+ for _, label := range labels {
+ unittest.AssertNotExistsBean(t, label)
+ }
+ assert.NoError(t, issues_model.NewLabels(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(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, label.ID)
+
+ _, err = issues_model.GetLabelByID(db.DefaultContext, unittest.NonexistentID)
+ assert.True(t, issues_model.IsErrLabelNotExist(err))
+}
+
+func TestGetLabelInRepoByName(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label, err := issues_model.GetLabelInRepoByName(db.DefaultContext, 1, "label1")
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, label.ID)
+ assert.Equal(t, "label1", label.Name)
+
+ _, err = issues_model.GetLabelInRepoByName(db.DefaultContext, 1, "")
+ assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInRepoByName(db.DefaultContext, 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(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(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(db.DefaultContext, 1, 1)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, label.ID)
+
+ _, err = issues_model.GetLabelInRepoByID(db.DefaultContext, 1, -1)
+ assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInRepoByID(db.DefaultContext, 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(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(db.DefaultContext, repoID, sortType, db.ListOptions{})
+ assert.NoError(t, err)
+ assert.Len(t, labels, len(expectedIssueIDs))
+ for i, label := range labels {
+ assert.EqualValues(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(db.DefaultContext, 3, "orglabel3")
+ assert.NoError(t, err)
+ assert.EqualValues(t, 3, label.ID)
+ assert.Equal(t, "orglabel3", label.Name)
+
+ _, err = issues_model.GetLabelInOrgByName(db.DefaultContext, 3, "")
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByName(db.DefaultContext, 0, "orglabel3")
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByName(db.DefaultContext, -1, "orglabel3")
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByName(db.DefaultContext, unittest.NonexistentID, "nonexistent")
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+}
+
+func TestGetLabelInOrgByNames(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ labelIDs, err := issues_model.GetLabelIDsInOrgByNames(3, []string{"orglabel3", "orglabel4"})
+ assert.NoError(t, err)
+
+ assert.Len(t, labelIDs, 2)
+
+ assert.Equal(t, int64(3), labelIDs[0])
+ assert.Equal(t, int64(4), labelIDs[1])
+}
+
+func TestGetLabelInOrgByNamesDiscardsNonExistentLabels(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ // orglabel99 doesn't exists.. See labels.yml
+ labelIDs, err := issues_model.GetLabelIDsInOrgByNames(3, []string{"orglabel3", "orglabel4", "orglabel99"})
+ assert.NoError(t, err)
+
+ assert.Len(t, labelIDs, 2)
+
+ assert.Equal(t, int64(3), labelIDs[0])
+ assert.Equal(t, int64(4), labelIDs[1])
+ assert.NoError(t, err)
+}
+
+func TestGetLabelInOrgByID(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label, err := issues_model.GetLabelInOrgByID(db.DefaultContext, 3, 3)
+ assert.NoError(t, err)
+ assert.EqualValues(t, 3, label.ID)
+
+ _, err = issues_model.GetLabelInOrgByID(db.DefaultContext, 3, -1)
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByID(db.DefaultContext, 0, 3)
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByID(db.DefaultContext, -1, 3)
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelInOrgByID(db.DefaultContext, 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(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(db.DefaultContext, orgID, sortType, db.ListOptions{})
+ assert.NoError(t, err)
+ assert.Len(t, labels, len(expectedIssueIDs))
+ for i, label := range labels {
+ assert.EqualValues(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})
+
+ var err error
+ _, err = issues_model.GetLabelsByOrgID(db.DefaultContext, 0, "leastissues", db.ListOptions{})
+ assert.True(t, issues_model.IsErrOrgLabelNotExist(err))
+
+ _, err = issues_model.GetLabelsByOrgID(db.DefaultContext, -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(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ if assert.Len(t, labels, 1) {
+ assert.EqualValues(t, 1, labels[0].ID)
+ }
+
+ labels, err = issues_model.GetLabelsByIssueID(db.DefaultContext, unittest.NonexistentID)
+ assert.NoError(t, err)
+ assert.Len(t, labels, 0)
+}
+
+func TestUpdateLabel(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}).(*issues_model.Label)
+ // make sure update wont overwrite it
+ update := &issues_model.Label{
+ ID: label.ID,
+ Color: "#ffff00",
+ Name: "newLabelName",
+ Description: label.Description,
+ }
+ label.Color = update.Color
+ label.Name = update.Name
+ assert.NoError(t, issues_model.UpdateLabel(update))
+ newLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}).(*issues_model.Label)
+ assert.EqualValues(t, label.ID, newLabel.ID)
+ assert.EqualValues(t, label.Color, newLabel.Color)
+ assert.EqualValues(t, label.Name, newLabel.Name)
+ assert.EqualValues(t, label.Description, newLabel.Description)
+ 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}).(*issues_model.Label)
+ assert.NoError(t, issues_model.DeleteLabel(label.RepoID, label.ID))
+ unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
+
+ assert.NoError(t, issues_model.DeleteLabel(label.RepoID, label.ID))
+ unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
+
+ assert.NoError(t, issues_model.DeleteLabel(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(db.DefaultContext, 1, 1))
+ assert.False(t, issues_model.HasIssueLabel(db.DefaultContext, 1, 2))
+ assert.False(t, issues_model.HasIssueLabel(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID))
+}
+
+func TestNewIssueLabel(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}).(*issues_model.Label)
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}).(*issues_model.Issue)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+
+ // add new IssueLabel
+ prevNumIssues := label.NumIssues
+ assert.NoError(t, issues_model.NewIssueLabel(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}).(*issues_model.Label)
+ assert.EqualValues(t, prevNumIssues+1, label.NumIssues)
+
+ // re-add existing IssueLabel
+ assert.NoError(t, issues_model.NewIssueLabel(issue, label, doer))
+ unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
+}
+
+func TestNewIssueLabels(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}).(*issues_model.Label)
+ label2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}).(*issues_model.Label)
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 5}).(*issues_model.Issue)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+
+ assert.NoError(t, issues_model.NewIssueLabels(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}).(*issues_model.Label)
+ assert.EqualValues(t, 3, label1.NumIssues)
+ assert.EqualValues(t, 1, label1.NumClosedIssues)
+ label2 = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2}).(*issues_model.Label)
+ assert.EqualValues(t, 1, label2.NumIssues)
+ assert.EqualValues(t, 1, label2.NumClosedIssues)
+
+ // corner case: test empty slice
+ assert.NoError(t, issues_model.NewIssueLabels(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}).(*issues_model.Label)
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID}).(*issues_model.Issue)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doerID}).(*user_model.User)
+
+ expectedNumIssues := label.NumIssues
+ expectedNumClosedIssues := label.NumClosedIssues
+ if unittest.BeanExists(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID}) {
+ expectedNumIssues--
+ if issue.IsClosed {
+ expectedNumClosedIssues--
+ }
+ }
+
+ ctx, committer, err := db.TxContext()
+ 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}).(*issues_model.Label)
+ assert.EqualValues(t, expectedNumIssues, label.NumIssues)
+ assert.EqualValues(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{})
+}
diff --git a/models/issues/main_test.go b/models/issues/main_test.go
index 30f6ff02fb..e34bef62ca 100644
--- a/models/issues/main_test.go
+++ b/models/issues/main_test.go
@@ -2,14 +2,20 @@
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
-package issues
+package issues_test
import (
"path/filepath"
"testing"
+ _ "code.gitea.io/gitea/models"
+ issues_model "code.gitea.io/gitea/models/issues"
+ _ "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
+ _ "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
)
func init() {
@@ -17,14 +23,18 @@ func init() {
setting.LoadForTest()
}
+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, &unittest.TestOptions{
GiteaRootPath: filepath.Join("..", ".."),
- FixtureFiles: []string{
- "reaction.yml",
- "user.yml",
- "repository.yml",
- "milestone.yml",
- },
})
}
diff --git a/models/issues/milestone.go b/models/issues/milestone.go
index f7172f6448..6c10959108 100644
--- a/models/issues/milestone.go
+++ b/models/issues/milestone.go
@@ -292,11 +292,17 @@ func DeleteMilestoneByRepoID(repoID, id int64) error {
return err
}
- numMilestones, err := countRepoMilestones(ctx, repo.ID)
+ numMilestones, err := CountMilestones(ctx, GetMilestonesOption{
+ RepoID: repo.ID,
+ State: api.StateAll,
+ })
if err != nil {
return err
}
- numClosedMilestones, err := countRepoClosedMilestones(ctx, repo.ID)
+ numClosedMilestones, err := CountMilestones(ctx, GetMilestonesOption{
+ RepoID: repo.ID,
+ State: api.StateClosed,
+ })
if err != nil {
return err
}
@@ -428,13 +434,6 @@ func GetMilestonesByRepoIDs(repoIDs []int64, page int, isClosed bool, sortType s
)
}
-// ____ _ _
-// / ___|| |_ __ _| |_ ___
-// \___ \| __/ _` | __/ __|
-// ___) | || (_| | |_\__ \
-// |____/ \__\__,_|\__|___/
-//
-
// MilestonesStats represents milestone statistic information.
type MilestonesStats struct {
OpenCount, ClosedCount int64
@@ -503,23 +502,13 @@ func GetMilestonesStatsByRepoCondAndKw(repoCond builder.Cond, keyword string) (*
return stats, nil
}
-func countRepoMilestones(ctx context.Context, repoID int64) (int64, error) {
- return db.GetEngine(ctx).
- Where("repo_id=?", repoID).
- Count(new(Milestone))
-}
-
-func countRepoClosedMilestones(ctx context.Context, repoID int64) (int64, error) {
+// CountMilestones returns number of milestones in given repository with other options
+func CountMilestones(ctx context.Context, opts GetMilestonesOption) (int64, error) {
return db.GetEngine(ctx).
- Where("repo_id=? AND is_closed=?", repoID, true).
+ Where(opts.toCond()).
Count(new(Milestone))
}
-// CountRepoClosedMilestones returns number of closed milestones in given repository.
-func CountRepoClosedMilestones(repoID int64) (int64, error) {
- return countRepoClosedMilestones(db.DefaultContext, repoID)
-}
-
// CountMilestonesByRepoCond map from repo conditions to number of milestones matching the options`
func CountMilestonesByRepoCond(repoCond builder.Cond, isClosed bool) (map[int64]int64, error) {
sess := db.GetEngine(db.DefaultContext).Where("is_closed = ?", isClosed)
diff --git a/models/issues/milestone_test.go b/models/issues/milestone_test.go
index e087318320..a6fbf9c23b 100644
--- a/models/issues/milestone_test.go
+++ b/models/issues/milestone_test.go
@@ -2,44 +2,46 @@
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
-package issues
+package issues_test
import (
"sort"
"testing"
"code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
"xorm.io/builder"
)
func TestMilestone_State(t *testing.T) {
- assert.Equal(t, api.StateOpen, (&Milestone{IsClosed: false}).State())
- assert.Equal(t, api.StateClosed, (&Milestone{IsClosed: true}).State())
+ 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 := GetMilestoneByRepoID(db.DefaultContext, 1, 1)
+ milestone, err := issues_model.GetMilestoneByRepoID(db.DefaultContext, 1, 1)
assert.NoError(t, err)
assert.EqualValues(t, 1, milestone.ID)
assert.EqualValues(t, 1, milestone.RepoID)
- _, err = GetMilestoneByRepoID(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID)
- assert.True(t, IsErrMilestoneNotExist(err))
+ _, err = issues_model.GetMilestoneByRepoID(db.DefaultContext, 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) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}).(*repo_model.Repository)
- milestones, _, err := GetMilestones(GetMilestonesOption{
+ milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{
RepoID: repo.ID,
State: state,
})
@@ -76,7 +78,7 @@ func TestGetMilestonesByRepoID(t *testing.T) {
test(3, api.StateClosed)
test(3, api.StateAll)
- milestones, _, err := GetMilestones(GetMilestonesOption{
+ milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{
RepoID: unittest.NonexistentID,
State: api.StateOpen,
})
@@ -87,9 +89,9 @@ func TestGetMilestonesByRepoID(t *testing.T) {
func TestGetMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository)
- test := func(sortType string, sortCond func(*Milestone) int) {
+ test := func(sortType string, sortCond func(*issues_model.Milestone) int) {
for _, page := range []int{0, 1} {
- milestones, _, err := GetMilestones(GetMilestonesOption{
+ milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
@@ -106,7 +108,7 @@ func TestGetMilestones(t *testing.T) {
}
assert.True(t, sort.IntsAreSorted(values))
- milestones, _, err = GetMilestones(GetMilestonesOption{
+ milestones, _, err = issues_model.GetMilestones(issues_model.GetMilestonesOption{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
@@ -125,22 +127,22 @@ func TestGetMilestones(t *testing.T) {
assert.True(t, sort.IntsAreSorted(values))
}
}
- test("furthestduedate", func(milestone *Milestone) int {
+ test("furthestduedate", func(milestone *issues_model.Milestone) int {
return -int(milestone.DeadlineUnix)
})
- test("leastcomplete", func(milestone *Milestone) int {
+ test("leastcomplete", func(milestone *issues_model.Milestone) int {
return milestone.Completeness
})
- test("mostcomplete", func(milestone *Milestone) int {
+ test("mostcomplete", func(milestone *issues_model.Milestone) int {
return -milestone.Completeness
})
- test("leastissues", func(milestone *Milestone) int {
+ test("leastissues", func(milestone *issues_model.Milestone) int {
return milestone.NumIssues
})
- test("mostissues", func(milestone *Milestone) int {
+ test("mostissues", func(milestone *issues_model.Milestone) int {
return -milestone.NumIssues
})
- test("soonestduedate", func(milestone *Milestone) int {
+ test("soonestduedate", func(milestone *issues_model.Milestone) int {
return int(milestone.DeadlineUnix)
})
}
@@ -149,7 +151,10 @@ func TestCountRepoMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(repoID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}).(*repo_model.Repository)
- count, err := countRepoMilestones(db.DefaultContext, repoID)
+ count, err := issues_model.CountMilestones(db.DefaultContext, issues_model.GetMilestonesOption{
+ RepoID: repoID,
+ State: api.StateAll,
+ })
assert.NoError(t, err)
assert.EqualValues(t, repo.NumMilestones, count)
}
@@ -157,7 +162,10 @@ func TestCountRepoMilestones(t *testing.T) {
test(2)
test(3)
- count, err := countRepoMilestones(db.DefaultContext, unittest.NonexistentID)
+ count, err := issues_model.CountMilestones(db.DefaultContext, issues_model.GetMilestonesOption{
+ RepoID: unittest.NonexistentID,
+ State: api.StateAll,
+ })
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
}
@@ -166,7 +174,10 @@ func TestCountRepoClosedMilestones(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(repoID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}).(*repo_model.Repository)
- count, err := CountRepoClosedMilestones(repoID)
+ count, err := issues_model.CountMilestones(db.DefaultContext, issues_model.GetMilestonesOption{
+ RepoID: repoID,
+ State: api.StateClosed,
+ })
assert.NoError(t, err)
assert.EqualValues(t, repo.NumClosedMilestones, count)
}
@@ -174,7 +185,10 @@ func TestCountRepoClosedMilestones(t *testing.T) {
test(2)
test(3)
- count, err := CountRepoClosedMilestones(unittest.NonexistentID)
+ count, err := issues_model.CountMilestones(db.DefaultContext, issues_model.GetMilestonesOption{
+ RepoID: unittest.NonexistentID,
+ State: api.StateClosed,
+ })
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
}
@@ -188,12 +202,12 @@ func TestCountMilestonesByRepoIDs(t *testing.T) {
repo1OpenCount, repo1ClosedCount := milestonesCount(1)
repo2OpenCount, repo2ClosedCount := milestonesCount(2)
- openCounts, err := CountMilestonesByRepoCond(builder.In("repo_id", []int64{1, 2}), false)
+ openCounts, err := issues_model.CountMilestonesByRepoCond(builder.In("repo_id", []int64{1, 2}), false)
assert.NoError(t, err)
assert.EqualValues(t, repo1OpenCount, openCounts[1])
assert.EqualValues(t, repo2OpenCount, openCounts[2])
- closedCounts, err := CountMilestonesByRepoCond(builder.In("repo_id", []int64{1, 2}), true)
+ closedCounts, err := issues_model.CountMilestonesByRepoCond(builder.In("repo_id", []int64{1, 2}), true)
assert.NoError(t, err)
assert.EqualValues(t, repo1ClosedCount, closedCounts[1])
assert.EqualValues(t, repo2ClosedCount, closedCounts[2])
@@ -203,9 +217,9 @@ func TestGetMilestonesByRepoIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository)
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}).(*repo_model.Repository)
- test := func(sortType string, sortCond func(*Milestone) int) {
+ test := func(sortType string, sortCond func(*issues_model.Milestone) int) {
for _, page := range []int{0, 1} {
- openMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, false, sortType)
+ openMilestones, err := issues_model.GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, false, sortType)
assert.NoError(t, err)
assert.Len(t, openMilestones, repo1.NumOpenMilestones+repo2.NumOpenMilestones)
values := make([]int, len(openMilestones))
@@ -214,7 +228,7 @@ func TestGetMilestonesByRepoIDs(t *testing.T) {
}
assert.True(t, sort.IntsAreSorted(values))
- closedMilestones, err := GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, true, sortType)
+ closedMilestones, err := issues_model.GetMilestonesByRepoIDs([]int64{repo1.ID, repo2.ID}, page, true, sortType)
assert.NoError(t, err)
assert.Len(t, closedMilestones, repo1.NumClosedMilestones+repo2.NumClosedMilestones)
values = make([]int, len(closedMilestones))
@@ -224,22 +238,22 @@ func TestGetMilestonesByRepoIDs(t *testing.T) {
assert.True(t, sort.IntsAreSorted(values))
}
}
- test("furthestduedate", func(milestone *Milestone) int {
+ test("furthestduedate", func(milestone *issues_model.Milestone) int {
return -int(milestone.DeadlineUnix)
})
- test("leastcomplete", func(milestone *Milestone) int {
+ test("leastcomplete", func(milestone *issues_model.Milestone) int {
return milestone.Completeness
})
- test("mostcomplete", func(milestone *Milestone) int {
+ test("mostcomplete", func(milestone *issues_model.Milestone) int {
return -milestone.Completeness
})
- test("leastissues", func(milestone *Milestone) int {
+ test("leastissues", func(milestone *issues_model.Milestone) int {
return milestone.NumIssues
})
- test("mostissues", func(milestone *Milestone) int {
+ test("mostissues", func(milestone *issues_model.Milestone) int {
return -milestone.NumIssues
})
- test("soonestduedate", func(milestone *Milestone) int {
+ test("soonestduedate", func(milestone *issues_model.Milestone) int {
return int(milestone.DeadlineUnix)
})
}
@@ -249,7 +263,7 @@ func TestGetMilestonesStats(t *testing.T) {
test := func(repoID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}).(*repo_model.Repository)
- stats, err := GetMilestonesStatsByRepoCond(builder.And(builder.Eq{"repo_id": repoID}))
+ stats, err := issues_model.GetMilestonesStatsByRepoCond(builder.And(builder.Eq{"repo_id": repoID}))
assert.NoError(t, err)
assert.EqualValues(t, repo.NumMilestones-repo.NumClosedMilestones, stats.OpenCount)
assert.EqualValues(t, repo.NumClosedMilestones, stats.ClosedCount)
@@ -258,7 +272,7 @@ func TestGetMilestonesStats(t *testing.T) {
test(2)
test(3)
- stats, err := GetMilestonesStatsByRepoCond(builder.And(builder.Eq{"repo_id": unittest.NonexistentID}))
+ stats, err := issues_model.GetMilestonesStatsByRepoCond(builder.And(builder.Eq{"repo_id": unittest.NonexistentID}))
assert.NoError(t, err)
assert.EqualValues(t, 0, stats.OpenCount)
assert.EqualValues(t, 0, stats.ClosedCount)
@@ -266,8 +280,75 @@ func TestGetMilestonesStats(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository)
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}).(*repo_model.Repository)
- milestoneStats, err := GetMilestonesStatsByRepoCond(builder.In("repo_id", []int64{repo1.ID, repo2.ID}))
+ milestoneStats, err := issues_model.GetMilestonesStatsByRepoCond(builder.In("repo_id", []int64{repo1.ID, repo2.ID}))
assert.NoError(t, err)
assert.EqualValues(t, repo1.NumOpenMilestones+repo2.NumOpenMilestones, milestoneStats.OpenCount)
assert.EqualValues(t, repo1.NumClosedMilestones+repo2.NumClosedMilestones, milestoneStats.ClosedCount)
}
+
+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(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}).(*issues_model.Milestone)
+
+ assert.NoError(t, issues_model.ChangeMilestoneStatus(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(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(1, 1))
+ unittest.AssertNotExistsBean(t, &issues_model.Milestone{ID: 1})
+ unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: 1})
+
+ assert.NoError(t, issues_model.DeleteMilestoneByRepoID(unittest.NonexistentID, unittest.NonexistentID))
+}
+
+func TestUpdateMilestone(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}).(*issues_model.Milestone)
+ milestone.Name = " newMilestoneName "
+ milestone.Content = "newMilestoneContent"
+ assert.NoError(t, issues_model.UpdateMilestone(milestone, milestone.IsClosed))
+ milestone = unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}).(*issues_model.Milestone)
+ assert.EqualValues(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").(*issues_model.Issue)
+
+ issue.IsClosed = true
+ issue.ClosedUnix = timeutil.TimeStampNow()
+ _, err := db.GetEngine(db.DefaultContext).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
+ assert.NoError(t, err)
+ assert.NoError(t, issues_model.UpdateMilestoneCounters(db.DefaultContext, issue.MilestoneID))
+ unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
+
+ issue.IsClosed = false
+ issue.ClosedUnix = 0
+ _, err = db.GetEngine(db.DefaultContext).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
+ assert.NoError(t, err)
+ assert.NoError(t, issues_model.UpdateMilestoneCounters(db.DefaultContext, issue.MilestoneID))
+ unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
+}
diff --git a/models/issues/pull.go b/models/issues/pull.go
new file mode 100644
index 0000000000..f2ca19b03e
--- /dev/null
+++ b/models/issues/pull.go
@@ -0,0 +1,838 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ pull_model "code.gitea.io/gitea/models/pull"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+// ErrPullRequestNotExist represents a "PullRequestNotExist" kind of error.
+type ErrPullRequestNotExist struct {
+ ID int64
+ IssueID int64
+ HeadRepoID int64
+ BaseRepoID int64
+ HeadBranch string
+ BaseBranch string
+}
+
+// IsErrPullRequestNotExist checks if an error is a ErrPullRequestNotExist.
+func IsErrPullRequestNotExist(err error) bool {
+ _, ok := err.(ErrPullRequestNotExist)
+ return ok
+}
+
+func (err ErrPullRequestNotExist) Error() string {
+ return fmt.Sprintf("pull request does not exist [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]",
+ err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBranch, err.BaseBranch)
+}
+
+// ErrPullRequestAlreadyExists represents a "PullRequestAlreadyExists"-error
+type ErrPullRequestAlreadyExists struct {
+ ID int64
+ IssueID int64
+ HeadRepoID int64
+ BaseRepoID int64
+ HeadBranch string
+ BaseBranch string
+}
+
+// IsErrPullRequestAlreadyExists checks if an error is a ErrPullRequestAlreadyExists.
+func IsErrPullRequestAlreadyExists(err error) bool {
+ _, ok := err.(ErrPullRequestAlreadyExists)
+ return ok
+}
+
+// Error does pretty-printing :D
+func (err ErrPullRequestAlreadyExists) Error() string {
+ return fmt.Sprintf("pull request already exists for these targets [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]",
+ err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBranch, err.BaseBranch)
+}
+
+// ErrPullRequestHeadRepoMissing represents a "ErrPullRequestHeadRepoMissing" error
+type ErrPullRequestHeadRepoMissing struct {
+ ID int64
+ HeadRepoID int64
+}
+
+// IsErrErrPullRequestHeadRepoMissing checks if an error is a ErrPullRequestHeadRepoMissing.
+func IsErrErrPullRequestHeadRepoMissing(err error) bool {
+ _, ok := err.(ErrPullRequestHeadRepoMissing)
+ return ok
+}
+
+// Error does pretty-printing :D
+func (err ErrPullRequestHeadRepoMissing) Error() string {
+ return fmt.Sprintf("pull request head repo missing [id: %d, head_repo_id: %d]",
+ err.ID, err.HeadRepoID)
+}
+
+// ErrPullWasClosed is used close a closed pull request
+type ErrPullWasClosed struct {
+ ID int64
+ Index int64
+}
+
+// IsErrPullWasClosed checks if an error is a ErrErrPullWasClosed.
+func IsErrPullWasClosed(err error) bool {
+ _, ok := err.(ErrPullWasClosed)
+ return ok
+}
+
+func (err ErrPullWasClosed) Error() string {
+ return fmt.Sprintf("Pull request [%d] %d was already closed", err.ID, err.Index)
+}
+
+// PullRequestType defines pull request type
+type PullRequestType int
+
+// Enumerate all the pull request types
+const (
+ PullRequestGitea PullRequestType = iota
+ PullRequestGit
+)
+
+// PullRequestStatus defines pull request status
+type PullRequestStatus int
+
+// Enumerate all the pull request status
+const (
+ PullRequestStatusConflict PullRequestStatus = iota
+ PullRequestStatusChecking
+ PullRequestStatusMergeable
+ PullRequestStatusManuallyMerged
+ PullRequestStatusError
+ PullRequestStatusEmpty
+)
+
+// PullRequestFlow the flow of pull request
+type PullRequestFlow int
+
+const (
+ // PullRequestFlowGithub github flow from head branch to base branch
+ PullRequestFlowGithub PullRequestFlow = iota
+ // PullRequestFlowAGit Agit flow pull request, head branch is not exist
+ PullRequestFlowAGit
+)
+
+// PullRequest represents relation between pull request and repositories.
+type PullRequest struct {
+ ID int64 `xorm:"pk autoincr"`
+ Type PullRequestType
+ Status PullRequestStatus
+ ConflictedFiles []string `xorm:"TEXT JSON"`
+ CommitsAhead int
+ CommitsBehind int
+
+ ChangedProtectedFiles []string `xorm:"TEXT JSON"`
+
+ IssueID int64 `xorm:"INDEX"`
+ Issue *Issue `xorm:"-"`
+ Index int64
+
+ HeadRepoID int64 `xorm:"INDEX"`
+ HeadRepo *repo_model.Repository `xorm:"-"`
+ BaseRepoID int64 `xorm:"INDEX"`
+ BaseRepo *repo_model.Repository `xorm:"-"`
+ HeadBranch string
+ HeadCommitID string `xorm:"-"`
+ BaseBranch string
+ ProtectedBranch *git_model.ProtectedBranch `xorm:"-"`
+ MergeBase string `xorm:"VARCHAR(40)"`
+ AllowMaintainerEdit bool `xorm:"NOT NULL DEFAULT false"`
+
+ HasMerged bool `xorm:"INDEX"`
+ MergedCommitID string `xorm:"VARCHAR(40)"`
+ MergerID int64 `xorm:"INDEX"`
+ Merger *user_model.User `xorm:"-"`
+ MergedUnix timeutil.TimeStamp `xorm:"updated INDEX"`
+
+ isHeadRepoLoaded bool `xorm:"-"`
+
+ Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"`
+}
+
+func init() {
+ db.RegisterModel(new(PullRequest))
+}
+
+// DeletePullsByBaseRepoID deletes all pull requests by the base repository ID
+func DeletePullsByBaseRepoID(ctx context.Context, repoID int64) error {
+ deleteCond := builder.Select("id").From("pull_request").Where(builder.Eq{"pull_request.base_repo_id": repoID})
+
+ // Delete scheduled auto merges
+ if _, err := db.GetEngine(ctx).In("pull_id", deleteCond).
+ Delete(&pull_model.AutoMerge{}); err != nil {
+ return err
+ }
+
+ // Delete review states
+ if _, err := db.GetEngine(ctx).In("pull_id", deleteCond).
+ Delete(&pull_model.ReviewState{}); err != nil {
+ return err
+ }
+
+ _, err := db.DeleteByBean(ctx, &PullRequest{BaseRepoID: repoID})
+ return err
+}
+
+// MustHeadUserName returns the HeadRepo's username if failed return blank
+func (pr *PullRequest) MustHeadUserName() string {
+ if err := pr.LoadHeadRepo(); err != nil {
+ if !repo_model.IsErrRepoNotExist(err) {
+ log.Error("LoadHeadRepo: %v", err)
+ } else {
+ log.Warn("LoadHeadRepo %d but repository does not exist: %v", pr.HeadRepoID, err)
+ }
+ return ""
+ }
+ if pr.HeadRepo == nil {
+ return ""
+ }
+ return pr.HeadRepo.OwnerName
+}
+
+// Note: don't try to get Issue because will end up recursive querying.
+func (pr *PullRequest) loadAttributes(ctx context.Context) (err error) {
+ if pr.HasMerged && pr.Merger == nil {
+ pr.Merger, err = user_model.GetUserByIDCtx(ctx, pr.MergerID)
+ if user_model.IsErrUserNotExist(err) {
+ pr.MergerID = -1
+ pr.Merger = user_model.NewGhostUser()
+ } else if err != nil {
+ return fmt.Errorf("getUserByID [%d]: %v", pr.MergerID, err)
+ }
+ }
+
+ return nil
+}
+
+// LoadAttributes loads pull request attributes from database
+func (pr *PullRequest) LoadAttributes() error {
+ return pr.loadAttributes(db.DefaultContext)
+}
+
+// LoadHeadRepoCtx loads the head repository
+func (pr *PullRequest) LoadHeadRepoCtx(ctx context.Context) (err error) {
+ if !pr.isHeadRepoLoaded && pr.HeadRepo == nil && pr.HeadRepoID > 0 {
+ if pr.HeadRepoID == pr.BaseRepoID {
+ if pr.BaseRepo != nil {
+ pr.HeadRepo = pr.BaseRepo
+ return nil
+ } else if pr.Issue != nil && pr.Issue.Repo != nil {
+ pr.HeadRepo = pr.Issue.Repo
+ return nil
+ }
+ }
+
+ pr.HeadRepo, err = repo_model.GetRepositoryByIDCtx(ctx, pr.HeadRepoID)
+ if err != nil && !repo_model.IsErrRepoNotExist(err) { // Head repo maybe deleted, but it should still work
+ return fmt.Errorf("getRepositoryByID(head): %v", err)
+ }
+ pr.isHeadRepoLoaded = true
+ }
+ return nil
+}
+
+// LoadHeadRepo loads the head repository
+func (pr *PullRequest) LoadHeadRepo() error {
+ return pr.LoadHeadRepoCtx(db.DefaultContext)
+}
+
+// LoadBaseRepo loads the target repository
+func (pr *PullRequest) LoadBaseRepo() error {
+ return pr.LoadBaseRepoCtx(db.DefaultContext)
+}
+
+// LoadBaseRepoCtx loads the target repository
+func (pr *PullRequest) LoadBaseRepoCtx(ctx context.Context) (err error) {
+ if pr.BaseRepo != nil {
+ return nil
+ }
+
+ if pr.HeadRepoID == pr.BaseRepoID && pr.HeadRepo != nil {
+ pr.BaseRepo = pr.HeadRepo
+ return nil
+ }
+
+ if pr.Issue != nil && pr.Issue.Repo != nil {
+ pr.BaseRepo = pr.Issue.Repo
+ return nil
+ }
+
+ pr.BaseRepo, err = repo_model.GetRepositoryByIDCtx(ctx, pr.BaseRepoID)
+ if err != nil {
+ return fmt.Errorf("repo_model.GetRepositoryByID(base): %v", err)
+ }
+ return nil
+}
+
+// LoadIssue loads issue information from database
+func (pr *PullRequest) LoadIssue() (err error) {
+ return pr.LoadIssueCtx(db.DefaultContext)
+}
+
+// LoadIssueCtx loads issue information from database
+func (pr *PullRequest) LoadIssueCtx(ctx context.Context) (err error) {
+ if pr.Issue != nil {
+ return nil
+ }
+
+ pr.Issue, err = GetIssueByID(ctx, pr.IssueID)
+ if err == nil {
+ pr.Issue.PullRequest = pr
+ }
+ return err
+}
+
+// LoadProtectedBranch loads the protected branch of the base branch
+func (pr *PullRequest) LoadProtectedBranch() (err error) {
+ return pr.LoadProtectedBranchCtx(db.DefaultContext)
+}
+
+// LoadProtectedBranchCtx loads the protected branch of the base branch
+func (pr *PullRequest) LoadProtectedBranchCtx(ctx context.Context) (err error) {
+ if pr.ProtectedBranch == nil {
+ if pr.BaseRepo == nil {
+ if pr.BaseRepoID == 0 {
+ return nil
+ }
+ pr.BaseRepo, err = repo_model.GetRepositoryByIDCtx(ctx, pr.BaseRepoID)
+ if err != nil {
+ return
+ }
+ }
+ pr.ProtectedBranch, err = git_model.GetProtectedBranchBy(ctx, pr.BaseRepo.ID, pr.BaseBranch)
+ }
+ return
+}
+
+// ReviewCount represents a count of Reviews
+type ReviewCount struct {
+ IssueID int64
+ Type ReviewType
+ Count int64
+}
+
+// GetApprovalCounts returns the approval counts by type
+// FIXME: Only returns official counts due to double counting of non-official counts
+func (pr *PullRequest) GetApprovalCounts(ctx context.Context) ([]*ReviewCount, error) {
+ rCounts := make([]*ReviewCount, 0, 6)
+ sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID)
+ return rCounts, sess.Select("issue_id, type, count(id) as `count`").Where("official = ? AND dismissed = ?", true, false).GroupBy("issue_id, type").Table("review").Find(&rCounts)
+}
+
+// GetApprovers returns the approvers of the pull request
+func (pr *PullRequest) GetApprovers() string {
+ stringBuilder := strings.Builder{}
+ if err := pr.getReviewedByLines(&stringBuilder); err != nil {
+ log.Error("Unable to getReviewedByLines: Error: %v", err)
+ return ""
+ }
+
+ return stringBuilder.String()
+}
+
+func (pr *PullRequest) getReviewedByLines(writer io.Writer) error {
+ maxReviewers := setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers
+
+ if maxReviewers == 0 {
+ return nil
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // Note: This doesn't page as we only expect a very limited number of reviews
+ reviews, err := FindReviews(ctx, FindReviewOptions{
+ Type: ReviewTypeApprove,
+ IssueID: pr.IssueID,
+ OfficialOnly: setting.Repository.PullRequest.DefaultMergeMessageOfficialApproversOnly,
+ })
+ if err != nil {
+ log.Error("Unable to FindReviews for PR ID %d: %v", pr.ID, err)
+ return err
+ }
+
+ reviewersWritten := 0
+
+ for _, review := range reviews {
+ if maxReviewers > 0 && reviewersWritten > maxReviewers {
+ break
+ }
+
+ if err := review.loadReviewer(ctx); err != nil && !user_model.IsErrUserNotExist(err) {
+ log.Error("Unable to LoadReviewer[%d] for PR ID %d : %v", review.ReviewerID, pr.ID, err)
+ return err
+ } else if review.Reviewer == nil {
+ continue
+ }
+ if _, err := writer.Write([]byte("Reviewed-by: ")); err != nil {
+ return err
+ }
+ if _, err := writer.Write([]byte(review.Reviewer.NewGitSig().String())); err != nil {
+ return err
+ }
+ if _, err := writer.Write([]byte{'\n'}); err != nil {
+ return err
+ }
+ reviewersWritten++
+ }
+ return committer.Commit()
+}
+
+// GetGitRefName returns git ref for hidden pull request branch
+func (pr *PullRequest) GetGitRefName() string {
+ return fmt.Sprintf("%s%d/head", git.PullPrefix, pr.Index)
+}
+
+// IsChecking returns true if this pull request is still checking conflict.
+func (pr *PullRequest) IsChecking() bool {
+ return pr.Status == PullRequestStatusChecking
+}
+
+// CanAutoMerge returns true if this pull request can be merged automatically.
+func (pr *PullRequest) CanAutoMerge() bool {
+ return pr.Status == PullRequestStatusMergeable
+}
+
+// IsEmpty returns true if this pull request is empty.
+func (pr *PullRequest) IsEmpty() bool {
+ return pr.Status == PullRequestStatusEmpty
+}
+
+// SetMerged sets a pull request to merged and closes the corresponding issue
+func (pr *PullRequest) SetMerged(ctx context.Context) (bool, error) {
+ if pr.HasMerged {
+ return false, fmt.Errorf("PullRequest[%d] already merged", pr.Index)
+ }
+ if pr.MergedCommitID == "" || pr.MergedUnix == 0 || pr.Merger == nil {
+ return false, fmt.Errorf("Unable to merge PullRequest[%d], some required fields are empty", pr.Index)
+ }
+
+ pr.HasMerged = true
+ sess := db.GetEngine(ctx)
+
+ if _, err := sess.Exec("UPDATE `issue` SET `repo_id` = `repo_id` WHERE `id` = ?", pr.IssueID); err != nil {
+ return false, err
+ }
+
+ if _, err := sess.Exec("UPDATE `pull_request` SET `issue_id` = `issue_id` WHERE `id` = ?", pr.ID); err != nil {
+ return false, err
+ }
+
+ pr.Issue = nil
+ if err := pr.LoadIssueCtx(ctx); err != nil {
+ return false, err
+ }
+
+ if tmpPr, err := GetPullRequestByID(ctx, pr.ID); err != nil {
+ return false, err
+ } else if tmpPr.HasMerged {
+ if pr.Issue.IsClosed {
+ return false, nil
+ }
+ return false, fmt.Errorf("PullRequest[%d] already merged but it's associated issue [%d] is not closed", pr.Index, pr.IssueID)
+ } else if pr.Issue.IsClosed {
+ return false, fmt.Errorf("PullRequest[%d] already closed", pr.Index)
+ }
+
+ if err := pr.Issue.LoadRepo(ctx); err != nil {
+ return false, err
+ }
+
+ if err := pr.Issue.Repo.GetOwner(ctx); err != nil {
+ return false, err
+ }
+
+ if _, err := changeIssueStatus(ctx, pr.Issue, pr.Merger, true, true); err != nil {
+ return false, fmt.Errorf("Issue.changeStatus: %v", err)
+ }
+
+ // reset the conflicted files as there cannot be any if we're merged
+ pr.ConflictedFiles = []string{}
+
+ // We need to save all of the data used to compute this merge as it may have already been changed by TestPatch. FIXME: need to set some state to prevent TestPatch from running whilst we are merging.
+ if _, err := sess.Where("id = ?", pr.ID).Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").Update(pr); err != nil {
+ return false, fmt.Errorf("Failed to update pr[%d]: %v", pr.ID, err)
+ }
+
+ return true, nil
+}
+
+// NewPullRequest creates new pull request with labels for repository.
+func NewPullRequest(outerCtx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string, pr *PullRequest) (err error) {
+ idx, err := db.GetNextResourceIndex("issue_index", repo.ID)
+ if err != nil {
+ return fmt.Errorf("generate pull request index failed: %v", err)
+ }
+
+ issue.Index = idx
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ ctx.WithContext(outerCtx)
+
+ if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
+ Repo: repo,
+ Issue: issue,
+ LabelIDs: labelIDs,
+ Attachments: uuids,
+ IsPull: true,
+ }); err != nil {
+ if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
+ return err
+ }
+ return fmt.Errorf("newIssue: %v", err)
+ }
+
+ pr.Index = issue.Index
+ pr.BaseRepo = repo
+ pr.IssueID = issue.ID
+ if err = db.Insert(ctx, pr); err != nil {
+ return fmt.Errorf("insert pull repo: %v", err)
+ }
+
+ if err = committer.Commit(); err != nil {
+ return fmt.Errorf("Commit: %v", err)
+ }
+
+ return nil
+}
+
+// GetUnmergedPullRequest returns a pull request that is open and has not been merged
+// by given head/base and repo/branch.
+func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch string, flow PullRequestFlow) (*PullRequest, error) {
+ pr := new(PullRequest)
+ has, err := db.GetEngine(db.DefaultContext).
+ Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND flow = ? AND issue.is_closed=?",
+ headRepoID, headBranch, baseRepoID, baseBranch, false, flow, false).
+ Join("INNER", "issue", "issue.id=pull_request.issue_id").
+ Get(pr)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrPullRequestNotExist{0, 0, headRepoID, baseRepoID, headBranch, baseBranch}
+ }
+
+ return pr, nil
+}
+
+// GetLatestPullRequestByHeadInfo returns the latest pull request (regardless of its status)
+// by given head information (repo and branch).
+func GetLatestPullRequestByHeadInfo(repoID int64, branch string) (*PullRequest, error) {
+ pr := new(PullRequest)
+ has, err := db.GetEngine(db.DefaultContext).
+ Where("head_repo_id = ? AND head_branch = ? AND flow = ?", repoID, branch, PullRequestFlowGithub).
+ OrderBy("id DESC").
+ Get(pr)
+ if !has {
+ return nil, err
+ }
+ return pr, err
+}
+
+// GetPullRequestByIndex returns a pull request by the given index
+func GetPullRequestByIndex(ctx context.Context, repoID, index int64) (*PullRequest, error) {
+ if index < 1 {
+ return nil, ErrPullRequestNotExist{}
+ }
+ pr := &PullRequest{
+ BaseRepoID: repoID,
+ Index: index,
+ }
+
+ has, err := db.GetEngine(ctx).Get(pr)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrPullRequestNotExist{0, 0, 0, repoID, "", ""}
+ }
+
+ if err = pr.loadAttributes(ctx); err != nil {
+ return nil, err
+ }
+ if err = pr.LoadIssueCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ return pr, nil
+}
+
+// GetPullRequestByID returns a pull request by given ID.
+func GetPullRequestByID(ctx context.Context, id int64) (*PullRequest, error) {
+ pr := new(PullRequest)
+ has, err := db.GetEngine(ctx).ID(id).Get(pr)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrPullRequestNotExist{id, 0, 0, 0, "", ""}
+ }
+ return pr, pr.loadAttributes(ctx)
+}
+
+// GetPullRequestByIssueIDWithNoAttributes returns pull request with no attributes loaded by given issue ID.
+func GetPullRequestByIssueIDWithNoAttributes(issueID int64) (*PullRequest, error) {
+ var pr PullRequest
+ has, err := db.GetEngine(db.DefaultContext).Where("issue_id = ?", issueID).Get(&pr)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, ErrPullRequestNotExist{0, issueID, 0, 0, "", ""}
+ }
+ return &pr, nil
+}
+
+// GetPullRequestByIssueID returns pull request by given issue ID.
+func GetPullRequestByIssueID(ctx context.Context, issueID int64) (*PullRequest, error) {
+ pr := &PullRequest{
+ IssueID: issueID,
+ }
+ has, err := db.GetByBean(ctx, pr)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrPullRequestNotExist{0, issueID, 0, 0, "", ""}
+ }
+ return pr, pr.loadAttributes(ctx)
+}
+
+// GetAllUnmergedAgitPullRequestByPoster get all unmerged agit flow pull request
+// By poster id.
+func GetAllUnmergedAgitPullRequestByPoster(uid int64) ([]*PullRequest, error) {
+ pulls := make([]*PullRequest, 0, 10)
+
+ err := db.GetEngine(db.DefaultContext).
+ Where("has_merged=? AND flow = ? AND issue.is_closed=? AND issue.poster_id=?",
+ false, PullRequestFlowAGit, false, uid).
+ Join("INNER", "issue", "issue.id=pull_request.issue_id").
+ Find(&pulls)
+
+ return pulls, err
+}
+
+// Update updates all fields of pull request.
+func (pr *PullRequest) Update() error {
+ _, err := db.GetEngine(db.DefaultContext).ID(pr.ID).AllCols().Update(pr)
+ return err
+}
+
+// UpdateCols updates specific fields of pull request.
+func (pr *PullRequest) UpdateCols(cols ...string) error {
+ _, err := db.GetEngine(db.DefaultContext).ID(pr.ID).Cols(cols...).Update(pr)
+ return err
+}
+
+// UpdateColsIfNotMerged updates specific fields of a pull request if it has not been merged
+func (pr *PullRequest) UpdateColsIfNotMerged(cols ...string) error {
+ _, err := db.GetEngine(db.DefaultContext).Where("id = ? AND has_merged = ?", pr.ID, false).Cols(cols...).Update(pr)
+ return err
+}
+
+// IsWorkInProgress determine if the Pull Request is a Work In Progress by its title
+func (pr *PullRequest) IsWorkInProgress() bool {
+ if err := pr.LoadIssue(); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return false
+ }
+ return HasWorkInProgressPrefix(pr.Issue.Title)
+}
+
+// HasWorkInProgressPrefix determines if the given PR title has a Work In Progress prefix
+func HasWorkInProgressPrefix(title string) bool {
+ for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes {
+ if strings.HasPrefix(strings.ToUpper(title), strings.ToUpper(prefix)) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch.
+func (pr *PullRequest) IsFilesConflicted() bool {
+ return len(pr.ConflictedFiles) > 0
+}
+
+// GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress.
+// It returns an empty string when none were found
+func (pr *PullRequest) GetWorkInProgressPrefix() string {
+ if err := pr.LoadIssue(); err != nil {
+ log.Error("LoadIssue: %v", err)
+ return ""
+ }
+
+ for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes {
+ if strings.HasPrefix(strings.ToUpper(pr.Issue.Title), strings.ToUpper(prefix)) {
+ return pr.Issue.Title[0:len(prefix)]
+ }
+ }
+ return ""
+}
+
+// UpdateCommitDivergence update Divergence of a pull request
+func (pr *PullRequest) UpdateCommitDivergence(ctx context.Context, ahead, behind int) error {
+ if pr.ID == 0 {
+ return fmt.Errorf("pull ID is 0")
+ }
+ pr.CommitsAhead = ahead
+ pr.CommitsBehind = behind
+ _, err := db.GetEngine(ctx).ID(pr.ID).Cols("commits_ahead", "commits_behind").Update(pr)
+ return err
+}
+
+// IsSameRepo returns true if base repo and head repo is the same
+func (pr *PullRequest) IsSameRepo() bool {
+ return pr.BaseRepoID == pr.HeadRepoID
+}
+
+// GetPullRequestsByHeadBranch returns all prs by head branch
+// Since there could be multiple prs with the same head branch, this function returns a slice of prs
+func GetPullRequestsByHeadBranch(ctx context.Context, headBranch string, headRepoID int64) ([]*PullRequest, error) {
+ log.Trace("GetPullRequestsByHeadBranch: headBranch: '%s', headRepoID: '%d'", headBranch, headRepoID)
+ prs := make([]*PullRequest, 0, 2)
+ if err := db.GetEngine(ctx).Where(builder.Eq{"head_branch": headBranch, "head_repo_id": headRepoID}).
+ Find(&prs); err != nil {
+ return nil, err
+ }
+ return prs, nil
+}
+
+// GetBaseBranchHTMLURL returns the HTML URL of the base branch
+func (pr *PullRequest) GetBaseBranchHTMLURL() string {
+ if err := pr.LoadBaseRepo(); err != nil {
+ log.Error("LoadBaseRepo: %v", err)
+ return ""
+ }
+ if pr.BaseRepo == nil {
+ return ""
+ }
+ return pr.BaseRepo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(pr.BaseBranch)
+}
+
+// GetHeadBranchHTMLURL returns the HTML URL of the head branch
+func (pr *PullRequest) GetHeadBranchHTMLURL() string {
+ if pr.Flow == PullRequestFlowAGit {
+ return ""
+ }
+
+ if err := pr.LoadHeadRepo(); err != nil {
+ log.Error("LoadHeadRepo: %v", err)
+ return ""
+ }
+ if pr.HeadRepo == nil {
+ return ""
+ }
+ return pr.HeadRepo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(pr.HeadBranch)
+}
+
+// UpdateAllowEdits update if PR can be edited from maintainers
+func UpdateAllowEdits(ctx context.Context, pr *PullRequest) error {
+ if _, err := db.GetEngine(ctx).ID(pr.ID).Cols("allow_maintainer_edit").Update(pr); err != nil {
+ return err
+ }
+ return nil
+}
+
+// Mergeable returns if the pullrequest is mergeable.
+func (pr *PullRequest) Mergeable() bool {
+ // If a pull request isn't mergable if it's:
+ // - Being conflict checked.
+ // - Has a conflict.
+ // - Received a error while being conflict checked.
+ // - Is a work-in-progress pull request.
+ return pr.Status != PullRequestStatusChecking && pr.Status != PullRequestStatusConflict &&
+ pr.Status != PullRequestStatusError && !pr.IsWorkInProgress()
+}
+
+// HasEnoughApprovals returns true if pr has enough granted approvals.
+func HasEnoughApprovals(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
+ if protectBranch.RequiredApprovals == 0 {
+ return true
+ }
+ return GetGrantedApprovalsCount(ctx, protectBranch, pr) >= protectBranch.RequiredApprovals
+}
+
+// GetGrantedApprovalsCount returns the number of granted approvals for pr. A granted approval must be authored by a user in an approval whitelist.
+func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
+ sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
+ And("type = ?", ReviewTypeApprove).
+ And("official = ?", true).
+ And("dismissed = ?", false)
+ if protectBranch.DismissStaleApprovals {
+ sess = sess.And("stale = ?", false)
+ }
+ approvals, err := sess.Count(new(Review))
+ if err != nil {
+ log.Error("GetGrantedApprovalsCount: %v", err)
+ return 0
+ }
+
+ return approvals
+}
+
+// MergeBlockedByRejectedReview returns true if merge is blocked by rejected reviews
+func MergeBlockedByRejectedReview(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
+ if !protectBranch.BlockOnRejectedReviews {
+ return false
+ }
+ rejectExist, err := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
+ And("type = ?", ReviewTypeReject).
+ And("official = ?", true).
+ And("dismissed = ?", false).
+ Exist(new(Review))
+ if err != nil {
+ log.Error("MergeBlockedByRejectedReview: %v", err)
+ return true
+ }
+
+ return rejectExist
+}
+
+// MergeBlockedByOfficialReviewRequests block merge because of some review request to official reviewer
+// of from official review
+func MergeBlockedByOfficialReviewRequests(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
+ if !protectBranch.BlockOnOfficialReviewRequests {
+ return false
+ }
+ has, err := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
+ And("type = ?", ReviewTypeRequest).
+ And("official = ?", true).
+ Exist(new(Review))
+ if err != nil {
+ log.Error("MergeBlockedByOfficialReviewRequests: %v", err)
+ return true
+ }
+
+ return has
+}
+
+// MergeBlockedByOutdatedBranch returns true if merge is blocked by an outdated head branch
+func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
+ return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
+}
diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go
new file mode 100644
index 0000000000..9ca536909e
--- /dev/null
+++ b/models/issues/pull_list.go
@@ -0,0 +1,216 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+
+ "xorm.io/xorm"
+)
+
+// PullRequestsOptions holds the options for PRs
+type PullRequestsOptions struct {
+ db.ListOptions
+ State string
+ SortType string
+ Labels []string
+ MilestoneID int64
+}
+
+func listPullRequestStatement(baseRepoID int64, opts *PullRequestsOptions) (*xorm.Session, error) {
+ sess := db.GetEngine(db.DefaultContext).Where("pull_request.base_repo_id=?", baseRepoID)
+
+ 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 labelIDs, err := base.StringsToInt64s(opts.Labels); err != nil {
+ return nil, err
+ } else if len(labelIDs) > 0 {
+ sess.Join("INNER", "issue_label", "issue.id = issue_label.issue_id").
+ In("issue_label.label_id", labelIDs)
+ }
+
+ if opts.MilestoneID > 0 {
+ sess.And("issue.milestone_id=?", opts.MilestoneID)
+ }
+
+ return sess, nil
+}
+
+// GetUnmergedPullRequestsByHeadInfo returns all pull requests that are open and has not been merged
+// by given head information (repo and branch).
+func GetUnmergedPullRequestsByHeadInfo(repoID int64, branch string) ([]*PullRequest, error) {
+ prs := make([]*PullRequest, 0, 2)
+ return prs, db.GetEngine(db.DefaultContext).
+ 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").
+ Find(&prs)
+}
+
+// CanMaintainerWriteToBranch check whether user is a matainer and could write to the branch
+func CanMaintainerWriteToBranch(p access_model.Permission, branch string, user *user_model.User) bool {
+ if p.CanWrite(unit.TypeCode) {
+ return true
+ }
+
+ if len(p.Units) < 1 {
+ return false
+ }
+
+ prs, err := GetUnmergedPullRequestsByHeadInfo(p.Units[0].RepoID, branch)
+ if err != nil {
+ return false
+ }
+
+ for _, pr := range prs {
+ if pr.AllowMaintainerEdit {
+ err = pr.LoadBaseRepo()
+ if err != nil {
+ continue
+ }
+ prPerm, err := access_model.GetUserRepoPermission(db.DefaultContext, pr.BaseRepo, user)
+ if err != nil {
+ continue
+ }
+ if prPerm.CanWrite(unit.TypeCode) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// 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(repoID int64, branch string) ([]*PullRequest, error) {
+ prs := make([]*PullRequest, 0, 2)
+ return prs, db.GetEngine(db.DefaultContext).
+ Where("base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?",
+ repoID, branch, false, false).
+ Join("INNER", "issue", "issue.id=pull_request.issue_id").
+ Find(&prs)
+}
+
+// GetPullRequestIDsByCheckStatus returns all pull requests according the special checking status.
+func GetPullRequestIDsByCheckStatus(status PullRequestStatus) ([]int64, error) {
+ prs := make([]int64, 0, 10)
+ return prs, db.GetEngine(db.DefaultContext).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(baseRepoID int64, opts *PullRequestsOptions) ([]*PullRequest, int64, error) {
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+
+ countSession, err := listPullRequestStatement(baseRepoID, opts)
+ if err != nil {
+ log.Error("listPullRequestStatement: %v", err)
+ return nil, 0, err
+ }
+ maxResults, err := countSession.Count(new(PullRequest))
+ if err != nil {
+ log.Error("Count PRs: %v", err)
+ return nil, maxResults, err
+ }
+
+ findSession, err := listPullRequestStatement(baseRepoID, opts)
+ sortIssuesSession(findSession, opts.SortType, 0)
+ if err != nil {
+ log.Error("listPullRequestStatement: %v", err)
+ return nil, maxResults, err
+ }
+ findSession = db.SetSessionPagination(findSession, opts)
+ prs := make([]*PullRequest, 0, opts.PageSize)
+ return prs, maxResults, findSession.Find(&prs)
+}
+
+// PullRequestList defines a list of pull requests
+type PullRequestList []*PullRequest
+
+func (prs PullRequestList) loadAttributes(ctx context.Context) error {
+ if len(prs) == 0 {
+ return nil
+ }
+
+ // Load issues.
+ issueIDs := prs.getIssueIDs()
+ issues := make([]*Issue, 0, len(issueIDs))
+ if err := db.GetEngine(ctx).
+ Where("id > 0").
+ In("id", issueIDs).
+ Find(&issues); err != nil {
+ return fmt.Errorf("find issues: %v", err)
+ }
+
+ set := make(map[int64]*Issue)
+ for i := range issues {
+ set[issues[i].ID] = issues[i]
+ }
+ for i := range prs {
+ prs[i].Issue = set[prs[i].IssueID]
+ }
+ return nil
+}
+
+func (prs PullRequestList) getIssueIDs() []int64 {
+ issueIDs := make([]int64, 0, len(prs))
+ for i := range prs {
+ issueIDs = append(issueIDs, prs[i].IssueID)
+ }
+ return issueIDs
+}
+
+// LoadAttributes load all the prs attributes
+func (prs PullRequestList) LoadAttributes() error {
+ return prs.loadAttributes(db.DefaultContext)
+}
+
+// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
+func (prs PullRequestList) InvalidateCodeComments(ctx context.Context, doer *user_model.User, repo *git.Repository, branch string) error {
+ if len(prs) == 0 {
+ return nil
+ }
+ issueIDs := prs.getIssueIDs()
+ var codeComments []*Comment
+ if err := db.GetEngine(ctx).
+ Where("type = ? and invalidated = ?", CommentTypeCode, false).
+ In("issue_id", issueIDs).
+ Find(&codeComments); err != nil {
+ return fmt.Errorf("find code comments: %v", err)
+ }
+ for _, comment := range codeComments {
+ if err := comment.CheckInvalidation(repo, doer, branch); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go
new file mode 100644
index 0000000000..0d1991383d
--- /dev/null
+++ b/models/issues/pull_test.go
@@ -0,0 +1,277 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullRequest_LoadAttributes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ assert.NoError(t, pr.LoadAttributes())
+ assert.NotNil(t, pr.Merger)
+ assert.Equal(t, pr.MergerID, pr.Merger.ID)
+}
+
+func TestPullRequest_LoadIssue(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ assert.NoError(t, pr.LoadIssue())
+ assert.NotNil(t, pr.Issue)
+ assert.Equal(t, int64(2), pr.Issue.ID)
+ assert.NoError(t, pr.LoadIssue())
+ assert.NotNil(t, pr.Issue)
+ assert.Equal(t, int64(2), pr.Issue.ID)
+}
+
+func TestPullRequest_LoadBaseRepo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ assert.NoError(t, pr.LoadBaseRepo())
+ assert.NotNil(t, pr.BaseRepo)
+ assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
+ assert.NoError(t, pr.LoadBaseRepo())
+ assert.NotNil(t, pr.BaseRepo)
+ assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID)
+}
+
+func TestPullRequest_LoadHeadRepo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ assert.NoError(t, pr.LoadHeadRepo())
+ assert.NotNil(t, pr.HeadRepo)
+ assert.Equal(t, pr.HeadRepoID, pr.HeadRepo.ID)
+}
+
+// TODO TestMerge
+
+// TODO TestNewPullRequest
+
+func TestPullRequestsNewest(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ prs, count, err := issues_model.PullRequests(1, &issues_model.PullRequestsOptions{
+ ListOptions: db.ListOptions{
+ Page: 1,
+ },
+ State: "open",
+ SortType: "newest",
+ Labels: []string{},
+ })
+ 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 TestPullRequestsOldest(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ prs, count, err := issues_model.PullRequests(1, &issues_model.PullRequestsOptions{
+ ListOptions: db.ListOptions{
+ Page: 1,
+ },
+ State: "open",
+ SortType: "oldest",
+ Labels: []string{},
+ })
+ 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) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr, err := issues_model.GetUnmergedPullRequest(1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(2), pr.ID)
+
+ _, err = issues_model.GetUnmergedPullRequest(1, 9223372036854775807, "branch1", "master", issues_model.PullRequestFlowGithub)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrPullRequestNotExist(err))
+}
+
+func TestHasUnmergedPullRequestsByHeadInfo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(db.DefaultContext, 1, "branch2")
+ assert.NoError(t, err)
+ assert.Equal(t, true, exist)
+
+ exist, err = issues_model.HasUnmergedPullRequestsByHeadInfo(db.DefaultContext, 1, "not_exist_branch")
+ assert.NoError(t, err)
+ assert.Equal(t, false, exist)
+}
+
+func TestGetUnmergedPullRequestsByHeadInfo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(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) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(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) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr, err := issues_model.GetPullRequestByIndex(db.DefaultContext, 1, 2)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(1), pr.BaseRepoID)
+ assert.Equal(t, int64(2), pr.Index)
+
+ _, err = issues_model.GetPullRequestByIndex(db.DefaultContext, 9223372036854775807, 9223372036854775807)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrPullRequestNotExist(err))
+
+ _, err = issues_model.GetPullRequestByIndex(db.DefaultContext, 1, 0)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrPullRequestNotExist(err))
+}
+
+func TestGetPullRequestByID(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr, err := issues_model.GetPullRequestByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(1), pr.ID)
+ assert.Equal(t, int64(2), pr.IssueID)
+
+ _, err = issues_model.GetPullRequestByID(db.DefaultContext, 9223372036854775807)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrPullRequestNotExist(err))
+}
+
+func TestGetPullRequestByIssueID(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr, err := issues_model.GetPullRequestByIssueID(db.DefaultContext, 2)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(2), pr.IssueID)
+
+ _, err = issues_model.GetPullRequestByIssueID(db.DefaultContext, 9223372036854775807)
+ assert.Error(t, err)
+ assert.True(t, issues_model.IsErrPullRequestNotExist(err))
+}
+
+func TestPullRequest_Update(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ pr.BaseBranch = "baseBranch"
+ pr.HeadBranch = "headBranch"
+ pr.Update()
+
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}).(*issues_model.PullRequest)
+ assert.Equal(t, "baseBranch", pr.BaseBranch)
+ assert.Equal(t, "headBranch", pr.HeadBranch)
+ unittest.CheckConsistencyFor(t, pr)
+}
+
+func TestPullRequest_UpdateCols(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ pr := &issues_model.PullRequest{
+ ID: 1,
+ BaseBranch: "baseBranch",
+ HeadBranch: "headBranch",
+ }
+ assert.NoError(t, pr.UpdateCols("head_branch"))
+
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest)
+ assert.Equal(t, "master", pr.BaseBranch)
+ assert.Equal(t, "headBranch", pr.HeadBranch)
+ unittest.CheckConsistencyFor(t, pr)
+}
+
+func TestPullRequestList_LoadAttributes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ prs := []*issues_model.PullRequest{
+ unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}).(*issues_model.PullRequest),
+ unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}).(*issues_model.PullRequest),
+ }
+ assert.NoError(t, issues_model.PullRequestList(prs).LoadAttributes())
+ 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())
+}
+
+// TODO TestAddTestPullRequestTask
+
+func TestPullRequest_IsWorkInProgress(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}).(*issues_model.PullRequest)
+ pr.LoadIssue()
+
+ assert.False(t, pr.IsWorkInProgress())
+
+ pr.Issue.Title = "WIP: " + pr.Issue.Title
+ assert.True(t, pr.IsWorkInProgress())
+
+ pr.Issue.Title = "[wip]: " + pr.Issue.Title
+ assert.True(t, pr.IsWorkInProgress())
+}
+
+func TestPullRequest_GetWorkInProgressPrefixWorkInProgress(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}).(*issues_model.PullRequest)
+ pr.LoadIssue()
+
+ assert.Empty(t, pr.GetWorkInProgressPrefix())
+
+ original := pr.Issue.Title
+ pr.Issue.Title = "WIP: " + original
+ assert.Equal(t, "WIP:", pr.GetWorkInProgressPrefix())
+
+ pr.Issue.Title = "[wip] " + original
+ assert.Equal(t, "[wip]", pr.GetWorkInProgressPrefix())
+}
+
+func TestDeleteOrphanedObjects(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ countBefore, err := db.GetEngine(db.DefaultContext).Count(&issues_model.PullRequest{})
+ assert.NoError(t, err)
+
+ _, err = db.GetEngine(db.DefaultContext).Insert(&issues_model.PullRequest{IssueID: 1000}, &issues_model.PullRequest{IssueID: 1001}, &issues_model.PullRequest{IssueID: 1003})
+ assert.NoError(t, err)
+
+ orphaned, err := db.CountOrphanedObjects("pull_request", "issue", "pull_request.issue_id=issue.id")
+ assert.NoError(t, err)
+ assert.EqualValues(t, 3, orphaned)
+
+ err = db.DeleteOrphanedObjects("pull_request", "issue", "pull_request.issue_id=issue.id")
+ assert.NoError(t, err)
+
+ countAfter, err := db.GetEngine(db.DefaultContext).Count(&issues_model.PullRequest{})
+ assert.NoError(t, err)
+ assert.EqualValues(t, countBefore, countAfter)
+}
diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go
index b1216a3a69..ee1b6687a2 100644
--- a/models/issues/reaction_test.go
+++ b/models/issues/reaction_test.go
@@ -2,12 +2,13 @@
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
-package issues
+package issues_test
import (
"testing"
"code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@@ -17,12 +18,12 @@ import (
)
func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) {
- var reaction *Reaction
+ var reaction *issues_model.Reaction
var err error
if commentID == 0 {
- reaction, err = CreateIssueReaction(doerID, issueID, content)
+ reaction, err = issues_model.CreateIssueReaction(doerID, issueID, content)
} else {
- reaction, err = CreateCommentReaction(doerID, issueID, commentID, content)
+ reaction, err = issues_model.CreateCommentReaction(doerID, issueID, commentID, content)
}
assert.NoError(t, err)
assert.NotNil(t, reaction)
@@ -37,7 +38,7 @@ func TestIssueAddReaction(t *testing.T) {
addReaction(t, user1.ID, issue1ID, 0, "heart")
- unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID})
}
func TestIssueAddDuplicateReaction(t *testing.T) {
@@ -49,15 +50,15 @@ func TestIssueAddDuplicateReaction(t *testing.T) {
addReaction(t, user1.ID, issue1ID, 0, "heart")
- reaction, err := CreateReaction(&ReactionOptions{
+ reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{
DoerID: user1.ID,
IssueID: issue1ID,
Type: "heart",
})
assert.Error(t, err)
- assert.Equal(t, ErrReactionAlreadyExist{Reaction: "heart"}, err)
+ assert.Equal(t, issues_model.ErrReactionAlreadyExist{Reaction: "heart"}, err)
- existingR := unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}).(*Reaction)
+ existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}).(*issues_model.Reaction)
assert.Equal(t, existingR.ID, reaction.ID)
}
@@ -70,10 +71,10 @@ func TestIssueDeleteReaction(t *testing.T) {
addReaction(t, user1.ID, issue1ID, 0, "heart")
- err := DeleteIssueReaction(user1.ID, issue1ID, "heart")
+ err := issues_model.DeleteIssueReaction(user1.ID, issue1ID, "heart")
assert.NoError(t, err)
- unittest.AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID})
+ unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID})
}
func TestIssueReactionCount(t *testing.T) {
@@ -98,7 +99,7 @@ func TestIssueReactionCount(t *testing.T) {
addReaction(t, user4.ID, issueID, 0, "heart")
addReaction(t, ghost.ID, issueID, 0, "-1")
- reactionsList, _, err := FindReactions(db.DefaultContext, FindReactionsOptions{
+ reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{
IssueID: issueID,
})
assert.NoError(t, err)
@@ -128,7 +129,7 @@ func TestIssueCommentAddReaction(t *testing.T) {
addReaction(t, user1.ID, issue1ID, comment1ID, "heart")
- unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID})
}
func TestIssueCommentDeleteReaction(t *testing.T) {
@@ -147,7 +148,7 @@ func TestIssueCommentDeleteReaction(t *testing.T) {
addReaction(t, user3.ID, issue1ID, comment1ID, "heart")
addReaction(t, user4.ID, issue1ID, comment1ID, "+1")
- reactionsList, _, err := FindReactions(db.DefaultContext, FindReactionsOptions{
+ reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{
IssueID: issue1ID,
CommentID: comment1ID,
})
@@ -168,7 +169,7 @@ func TestIssueCommentReactionCount(t *testing.T) {
var comment1ID int64 = 1
addReaction(t, user1.ID, issue1ID, comment1ID, "heart")
- assert.NoError(t, DeleteCommentReaction(user1.ID, issue1ID, comment1ID, "heart"))
+ assert.NoError(t, issues_model.DeleteCommentReaction(user1.ID, issue1ID, comment1ID, "heart"))
- unittest.AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID})
+ unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID})
}
diff --git a/models/issues/review.go b/models/issues/review.go
new file mode 100644
index 0000000000..ee65bec3f8
--- /dev/null
+++ b/models/issues/review.go
@@ -0,0 +1,1018 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/builder"
+)
+
+// ErrReviewNotExist represents a "ReviewNotExist" kind of error.
+type ErrReviewNotExist struct {
+ ID int64
+}
+
+// IsErrReviewNotExist checks if an error is a ErrReviewNotExist.
+func IsErrReviewNotExist(err error) bool {
+ _, ok := err.(ErrReviewNotExist)
+ return ok
+}
+
+func (err ErrReviewNotExist) Error() string {
+ return fmt.Sprintf("review does not exist [id: %d]", err.ID)
+}
+
+// ErrNotValidReviewRequest an not allowed review request modify
+type ErrNotValidReviewRequest struct {
+ Reason string
+ UserID int64
+ RepoID int64
+}
+
+// IsErrNotValidReviewRequest checks if an error is a ErrNotValidReviewRequest.
+func IsErrNotValidReviewRequest(err error) bool {
+ _, ok := err.(ErrNotValidReviewRequest)
+ return ok
+}
+
+func (err ErrNotValidReviewRequest) Error() string {
+ return fmt.Sprintf("%s [user_id: %d, repo_id: %d]",
+ err.Reason,
+ err.UserID,
+ err.RepoID)
+}
+
+// ReviewType defines the sort of feedback a review gives
+type ReviewType int
+
+// ReviewTypeUnknown unknown review type
+const ReviewTypeUnknown ReviewType = -1
+
+const (
+ // ReviewTypePending is a review which is not published yet
+ ReviewTypePending ReviewType = iota
+ // ReviewTypeApprove approves changes
+ ReviewTypeApprove
+ // ReviewTypeComment gives general feedback
+ ReviewTypeComment
+ // ReviewTypeReject gives feedback blocking merge
+ ReviewTypeReject
+ // ReviewTypeRequest request review from others
+ ReviewTypeRequest
+)
+
+// Icon returns the corresponding icon for the review type
+func (rt ReviewType) Icon() string {
+ switch rt {
+ case ReviewTypeApprove:
+ return "check"
+ case ReviewTypeReject:
+ return "diff"
+ case ReviewTypeComment:
+ return "comment"
+ case ReviewTypeRequest:
+ return "dot-fill"
+ default:
+ return "comment"
+ }
+}
+
+// Review represents collection of code comments giving feedback for a PR
+type Review struct {
+ ID int64 `xorm:"pk autoincr"`
+ Type ReviewType
+ Reviewer *user_model.User `xorm:"-"`
+ ReviewerID int64 `xorm:"index"`
+ ReviewerTeamID int64 `xorm:"NOT NULL DEFAULT 0"`
+ ReviewerTeam *organization.Team `xorm:"-"`
+ OriginalAuthor string
+ OriginalAuthorID int64
+ Issue *Issue `xorm:"-"`
+ IssueID int64 `xorm:"index"`
+ Content string `xorm:"TEXT"`
+ // Official is a review made by an assigned approver (counts towards approval)
+ Official bool `xorm:"NOT NULL DEFAULT false"`
+ CommitID string `xorm:"VARCHAR(40)"`
+ Stale bool `xorm:"NOT NULL DEFAULT false"`
+ Dismissed bool `xorm:"NOT NULL DEFAULT false"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+
+ // CodeComments are the initial code comments of the review
+ CodeComments CodeComments `xorm:"-"`
+
+ Comments []*Comment `xorm:"-"`
+}
+
+func init() {
+ db.RegisterModel(new(Review))
+}
+
+// LoadCodeComments loads CodeComments
+func (r *Review) LoadCodeComments(ctx context.Context) (err error) {
+ if r.CodeComments != nil {
+ return
+ }
+ if err = r.loadIssue(ctx); err != nil {
+ return
+ }
+ r.CodeComments, err = fetchCodeCommentsByReview(ctx, r.Issue, nil, r)
+ return
+}
+
+func (r *Review) loadIssue(ctx context.Context) (err error) {
+ if r.Issue != nil {
+ return
+ }
+ r.Issue, err = GetIssueByID(ctx, r.IssueID)
+ return
+}
+
+func (r *Review) loadReviewer(ctx context.Context) (err error) {
+ if r.ReviewerID == 0 || r.Reviewer != nil {
+ return
+ }
+ r.Reviewer, err = user_model.GetUserByIDCtx(ctx, r.ReviewerID)
+ return
+}
+
+func (r *Review) loadReviewerTeam(ctx context.Context) (err error) {
+ if r.ReviewerTeamID == 0 || r.ReviewerTeam != nil {
+ return
+ }
+
+ r.ReviewerTeam, err = organization.GetTeamByID(ctx, r.ReviewerTeamID)
+ return
+}
+
+// LoadReviewer loads reviewer
+func (r *Review) LoadReviewer() error {
+ return r.loadReviewer(db.DefaultContext)
+}
+
+// LoadReviewerTeam loads reviewer team
+func (r *Review) LoadReviewerTeam() error {
+ return r.loadReviewerTeam(db.DefaultContext)
+}
+
+// LoadAttributes loads all attributes except CodeComments
+func (r *Review) LoadAttributes(ctx context.Context) (err error) {
+ if err = r.loadIssue(ctx); err != nil {
+ return
+ }
+ if err = r.LoadCodeComments(ctx); err != nil {
+ return
+ }
+ if err = r.loadReviewer(ctx); err != nil {
+ return
+ }
+ if err = r.loadReviewerTeam(ctx); err != nil {
+ return
+ }
+ return
+}
+
+// GetReviewByID returns the review by the given ID
+func GetReviewByID(ctx context.Context, id int64) (*Review, error) {
+ review := new(Review)
+ if has, err := db.GetEngine(ctx).ID(id).Get(review); err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrReviewNotExist{ID: id}
+ } else {
+ return review, nil
+ }
+}
+
+// FindReviewOptions represent possible filters to find reviews
+type FindReviewOptions struct {
+ db.ListOptions
+ Type ReviewType
+ IssueID int64
+ ReviewerID int64
+ OfficialOnly 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 opts.Type != ReviewTypeUnknown {
+ cond = cond.And(builder.Eq{"type": opts.Type})
+ }
+ if opts.OfficialOnly {
+ cond = cond.And(builder.Eq{"official": true})
+ }
+ return cond
+}
+
+// FindReviews returns reviews passing FindReviewOptions
+func FindReviews(ctx context.Context, opts FindReviewOptions) ([]*Review, error) {
+ reviews := make([]*Review, 0, 10)
+ sess := db.GetEngine(ctx).Where(opts.toCond())
+ if opts.Page > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+ }
+ return reviews, sess.
+ Asc("created_unix").
+ Asc("id").
+ Find(&reviews)
+}
+
+// CountReviews returns count of reviews passing FindReviewOptions
+func CountReviews(opts FindReviewOptions) (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where(opts.toCond()).Count(&Review{})
+}
+
+// CreateReviewOptions represent the options to create a review. Type, Issue and Reviewer are required.
+type CreateReviewOptions struct {
+ Content string
+ Type ReviewType
+ Issue *Issue
+ Reviewer *user_model.User
+ ReviewerTeam *organization.Team
+ Official bool
+ CommitID string
+ Stale bool
+}
+
+// IsOfficialReviewer check if at least one of the provided reviewers can make official reviews in issue (counts towards required approvals)
+func IsOfficialReviewer(ctx context.Context, issue *Issue, reviewers ...*user_model.User) (bool, error) {
+ pr, err := GetPullRequestByIssueID(ctx, issue.ID)
+ if err != nil {
+ return false, err
+ }
+ if err = pr.LoadProtectedBranchCtx(ctx); err != nil {
+ return false, err
+ }
+ if pr.ProtectedBranch == nil {
+ return false, nil
+ }
+
+ for _, reviewer := range reviewers {
+ official, err := git_model.IsUserOfficialReviewerCtx(ctx, pr.ProtectedBranch, reviewer)
+ if official || err != nil {
+ return official, err
+ }
+ }
+
+ return false, nil
+}
+
+// IsOfficialReviewerTeam check if reviewer in this team can make official reviews in issue (counts towards required approvals)
+func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organization.Team) (bool, error) {
+ pr, err := GetPullRequestByIssueID(ctx, issue.ID)
+ if err != nil {
+ return false, err
+ }
+ if err = pr.LoadProtectedBranchCtx(ctx); err != nil {
+ return false, err
+ }
+ if pr.ProtectedBranch == nil {
+ return false, nil
+ }
+
+ if !pr.ProtectedBranch.EnableApprovalsWhitelist {
+ return team.UnitAccessModeCtx(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
+ }
+
+ return base.Int64sContains(pr.ProtectedBranch.ApprovalsWhitelistTeamIDs, team.ID), nil
+}
+
+// CreateReview creates a new review based on opts
+func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
+ review := &Review{
+ Type: opts.Type,
+ Issue: opts.Issue,
+ IssueID: opts.Issue.ID,
+ Reviewer: opts.Reviewer,
+ ReviewerTeam: opts.ReviewerTeam,
+ Content: opts.Content,
+ Official: opts.Official,
+ CommitID: opts.CommitID,
+ Stale: opts.Stale,
+ }
+ if opts.Reviewer != nil {
+ review.ReviewerID = opts.Reviewer.ID
+ } else {
+ if review.Type != ReviewTypeRequest {
+ review.Type = ReviewTypeRequest
+ }
+ review.ReviewerTeamID = opts.ReviewerTeam.ID
+ }
+ return review, db.Insert(ctx, review)
+}
+
+// GetCurrentReview returns the current pending review of reviewer for given issue
+func GetCurrentReview(ctx context.Context, reviewer *user_model.User, issue *Issue) (*Review, error) {
+ if reviewer == nil {
+ return nil, nil
+ }
+ reviews, err := FindReviews(ctx, FindReviewOptions{
+ Type: ReviewTypePending,
+ IssueID: issue.ID,
+ ReviewerID: reviewer.ID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(reviews) == 0 {
+ return nil, ErrReviewNotExist{}
+ }
+ reviews[0].Reviewer = reviewer
+ reviews[0].Issue = issue
+ return reviews[0], nil
+}
+
+// ReviewExists returns whether a review exists for a particular line of code in the PR
+func ReviewExists(issue *Issue, treePath string, line int64) (bool, error) {
+ return db.GetEngine(db.DefaultContext).Cols("id").Exist(&Comment{IssueID: issue.ID, TreePath: treePath, Line: line, Type: CommentTypeCode})
+}
+
+// ContentEmptyErr represents an content empty error
+type ContentEmptyErr struct{}
+
+func (ContentEmptyErr) Error() string {
+ return "Review content is empty"
+}
+
+// IsContentEmptyErr returns true if err is a ContentEmptyErr
+func IsContentEmptyErr(err error) bool {
+ _, ok := err.(ContentEmptyErr)
+ return ok
+}
+
+// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
+func SubmitReview(doer *user_model.User, issue *Issue, reviewType ReviewType, content, commitID string, stale bool, attachmentUUIDs []string) (*Review, *Comment, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, nil, err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ official := false
+
+ review, err := GetCurrentReview(ctx, doer, issue)
+ if err != nil {
+ if !IsErrReviewNotExist(err) {
+ return nil, nil, err
+ }
+
+ if reviewType != ReviewTypeApprove && len(strings.TrimSpace(content)) == 0 {
+ return nil, nil, ContentEmptyErr{}
+ }
+
+ if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
+ // Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
+ if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
+ return nil, nil, err
+ }
+ if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
+ return nil, nil, err
+ }
+ }
+
+ // No current review. Create a new one!
+ if review, err = CreateReview(ctx, CreateReviewOptions{
+ Type: reviewType,
+ Issue: issue,
+ Reviewer: doer,
+ Content: content,
+ Official: official,
+ CommitID: commitID,
+ Stale: stale,
+ }); err != nil {
+ return nil, nil, err
+ }
+ } else {
+ if err := review.LoadCodeComments(ctx); err != nil {
+ return nil, nil, err
+ }
+ if reviewType != ReviewTypeApprove && len(review.CodeComments) == 0 && len(strings.TrimSpace(content)) == 0 {
+ return nil, nil, ContentEmptyErr{}
+ }
+
+ if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
+ // Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
+ if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
+ return nil, nil, err
+ }
+ if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
+ return nil, nil, err
+ }
+ }
+
+ review.Official = official
+ review.Issue = issue
+ review.Content = content
+ review.Type = reviewType
+ review.CommitID = commitID
+ review.Stale = stale
+
+ if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil {
+ return nil, nil, err
+ }
+ }
+
+ comm, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeReview,
+ Doer: doer,
+ Content: review.Content,
+ Issue: issue,
+ Repo: issue.Repo,
+ ReviewID: review.ID,
+ Attachments: attachmentUUIDs,
+ })
+ if err != nil || comm == nil {
+ return nil, nil, err
+ }
+
+ // try to remove team review request if need
+ if issue.Repo.Owner.IsOrganization() && (reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject) {
+ teamReviewRequests := make([]*Review, 0, 10)
+ if err := sess.SQL("SELECT * FROM review WHERE issue_id = ? AND reviewer_team_id > 0 AND type = ?", issue.ID, ReviewTypeRequest).Find(&teamReviewRequests); err != nil {
+ return nil, nil, err
+ }
+
+ for _, teamReviewRequest := range teamReviewRequests {
+ ok, err := organization.IsTeamMember(ctx, issue.Repo.OwnerID, teamReviewRequest.ReviewerTeamID, doer.ID)
+ if err != nil {
+ return nil, nil, err
+ } else if !ok {
+ continue
+ }
+
+ if _, err := sess.ID(teamReviewRequest.ID).NoAutoCondition().Delete(teamReviewRequest); err != nil {
+ return nil, nil, err
+ }
+ }
+ }
+
+ comm.Review = review
+ return review, comm, committer.Commit()
+}
+
+// GetReviewersByIssueID gets the latest review of each reviewer for a pull request
+func GetReviewersByIssueID(issueID int64) ([]*Review, error) {
+ reviews := make([]*Review, 0, 10)
+
+ sess := db.GetEngine(db.DefaultContext)
+
+ // Get latest review of each reviewer, sorted in order they were made
+ if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND dismissed = ? AND original_author_id = 0 GROUP BY issue_id, reviewer_id) ORDER BY review.updated_unix ASC",
+ issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, false).
+ Find(&reviews); err != nil {
+ return nil, err
+ }
+
+ teamReviewRequests := make([]*Review, 0, 5)
+ if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id <> 0 AND original_author_id = 0 GROUP BY issue_id, reviewer_team_id) ORDER BY review.updated_unix ASC",
+ issueID).
+ Find(&teamReviewRequests); err != nil {
+ return nil, err
+ }
+
+ if len(teamReviewRequests) > 0 {
+ reviews = append(reviews, teamReviewRequests...)
+ }
+
+ return reviews, nil
+}
+
+// GetReviewersFromOriginalAuthorsByIssueID gets the latest review of each original authors for a pull request
+func GetReviewersFromOriginalAuthorsByIssueID(issueID int64) ([]*Review, error) {
+ reviews := make([]*Review, 0, 10)
+
+ // Get latest review of each reviewer, sorted in order they were made
+ if err := db.GetEngine(db.DefaultContext).SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND original_author_id <> 0 GROUP BY issue_id, original_author_id) ORDER BY review.updated_unix ASC",
+ issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest).
+ Find(&reviews); err != nil {
+ return nil, err
+ }
+
+ return reviews, nil
+}
+
+// GetReviewByIssueIDAndUserID get the latest review of reviewer for a pull request
+func GetReviewByIssueIDAndUserID(ctx context.Context, issueID, userID int64) (*Review, error) {
+ review := new(Review)
+
+ has, err := db.GetEngine(ctx).SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_id = ? AND original_author_id = 0 AND type in (?, ?, ?))",
+ issueID, userID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest).
+ Get(review)
+ if err != nil {
+ return nil, err
+ }
+
+ if !has {
+ return nil, ErrReviewNotExist{}
+ }
+
+ return review, nil
+}
+
+// GetTeamReviewerByIssueIDAndTeamID get the latest review request of reviewer team for a pull request
+func GetTeamReviewerByIssueIDAndTeamID(ctx context.Context, issueID, teamID int64) (review *Review, err error) {
+ review = new(Review)
+
+ has := false
+ if has, err = db.GetEngine(ctx).SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = ?)",
+ issueID, teamID).
+ Get(review); err != nil {
+ return nil, err
+ }
+
+ if !has {
+ return nil, ErrReviewNotExist{0}
+ }
+
+ return
+}
+
+// MarkReviewsAsStale marks existing reviews as stale
+func MarkReviewsAsStale(issueID int64) (err error) {
+ _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `review` SET stale=? WHERE issue_id=?", true, issueID)
+
+ return
+}
+
+// MarkReviewsAsNotStale marks existing reviews as not stale for a giving commit SHA
+func MarkReviewsAsNotStale(issueID int64, commitID string) (err error) {
+ _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `review` SET stale=? WHERE issue_id=? AND commit_id=?", false, issueID, commitID)
+
+ return
+}
+
+// DismissReview change the dismiss status of a review
+func DismissReview(review *Review, isDismiss bool) (err error) {
+ if review.Dismissed == isDismiss || (review.Type != ReviewTypeApprove && review.Type != ReviewTypeReject) {
+ return nil
+ }
+
+ review.Dismissed = isDismiss
+
+ if review.ID == 0 {
+ return ErrReviewNotExist{}
+ }
+
+ _, err = db.GetEngine(db.DefaultContext).ID(review.ID).Cols("dismissed").Update(review)
+
+ return
+}
+
+// InsertReviews inserts review and review comments
+func InsertReviews(reviews []*Review) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ for _, review := range reviews {
+ if _, err := sess.NoAutoTime().Insert(review); err != nil {
+ return err
+ }
+
+ if _, err := sess.NoAutoTime().Insert(&Comment{
+ Type: CommentTypeReview,
+ Content: review.Content,
+ PosterID: review.ReviewerID,
+ OriginalAuthor: review.OriginalAuthor,
+ OriginalAuthorID: review.OriginalAuthorID,
+ IssueID: review.IssueID,
+ ReviewID: review.ID,
+ CreatedUnix: review.CreatedUnix,
+ UpdatedUnix: review.UpdatedUnix,
+ }); err != nil {
+ return err
+ }
+
+ for _, c := range review.Comments {
+ c.ReviewID = review.ID
+ }
+
+ if len(review.Comments) > 0 {
+ if _, err := sess.NoAutoTime().Insert(review.Comments); err != nil {
+ return err
+ }
+ }
+ }
+
+ return committer.Commit()
+}
+
+// AddReviewRequest add a review request from one reviewer
+func AddReviewRequest(issue *Issue, reviewer, doer *user_model.User) (*Comment, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ // skip it when reviewer hase been request to review
+ if review != nil && review.Type == ReviewTypeRequest {
+ return nil, nil
+ }
+
+ official, err := IsOfficialReviewer(ctx, issue, reviewer, doer)
+ if err != nil {
+ return nil, err
+ } else if official {
+ if _, err := sess.Exec("UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, reviewer.ID); err != nil {
+ return nil, err
+ }
+ }
+
+ review, err = CreateReview(ctx, CreateReviewOptions{
+ Type: ReviewTypeRequest,
+ Issue: issue,
+ Reviewer: reviewer,
+ Official: official,
+ Stale: false,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ comment, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeReviewRequest,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ RemovedAssignee: false, // Use RemovedAssignee as !isRequest
+ AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID
+ ReviewID: review.ID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return comment, committer.Commit()
+}
+
+// RemoveReviewRequest remove a review request from one reviewer
+func RemoveReviewRequest(issue *Issue, reviewer, doer *user_model.User) (*Comment, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ if review == nil || review.Type != ReviewTypeRequest {
+ return nil, nil
+ }
+
+ if _, err = db.DeleteByBean(ctx, review); err != nil {
+ return nil, err
+ }
+
+ official, err := IsOfficialReviewer(ctx, issue, reviewer)
+ if err != nil {
+ return nil, err
+ } else if official {
+ // recalculate the latest official review for reviewer
+ review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ if review != nil {
+ if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ comment, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeReviewRequest,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ RemovedAssignee: true, // Use RemovedAssignee as !isRequest
+ AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return comment, committer.Commit()
+}
+
+// AddTeamReviewRequest add a review request from one team
+func AddTeamReviewRequest(issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ // This team already has been requested to review - therefore skip this.
+ if review != nil {
+ return nil, nil
+ }
+
+ official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
+ if err != nil {
+ return nil, fmt.Errorf("isOfficialReviewerTeam(): %v", err)
+ } else if !official {
+ if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
+ return nil, fmt.Errorf("isOfficialReviewer(): %v", err)
+ }
+ }
+
+ if review, err = CreateReview(ctx, CreateReviewOptions{
+ Type: ReviewTypeRequest,
+ Issue: issue,
+ ReviewerTeam: reviewer,
+ Official: official,
+ Stale: false,
+ }); err != nil {
+ return nil, err
+ }
+
+ if official {
+ if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_team_id=?", false, issue.ID, reviewer.ID); err != nil {
+ return nil, err
+ }
+ }
+
+ comment, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeReviewRequest,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ RemovedAssignee: false, // Use RemovedAssignee as !isRequest
+ AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID
+ ReviewID: review.ID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("CreateCommentCtx(): %v", err)
+ }
+
+ return comment, committer.Commit()
+}
+
+// RemoveTeamReviewRequest remove a review request from one team
+func RemoveTeamReviewRequest(issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ if review == nil {
+ return nil, nil
+ }
+
+ if _, err = db.DeleteByBean(ctx, review); err != nil {
+ return nil, err
+ }
+
+ official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
+ if err != nil {
+ return nil, fmt.Errorf("isOfficialReviewerTeam(): %v", err)
+ }
+
+ if official {
+ // recalculate which is the latest official review from that team
+ review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, -reviewer.ID)
+ if err != nil && !IsErrReviewNotExist(err) {
+ return nil, err
+ }
+
+ if review != nil {
+ if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if doer == nil {
+ return nil, committer.Commit()
+ }
+
+ comment, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Type: CommentTypeReviewRequest,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ RemovedAssignee: true, // Use RemovedAssignee as !isRequest
+ AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID
+ })
+ if err != nil {
+ return nil, fmt.Errorf("CreateCommentCtx(): %v", err)
+ }
+
+ return comment, committer.Commit()
+}
+
+// MarkConversation Add or remove Conversation mark for a code comment
+func MarkConversation(comment *Comment, doer *user_model.User, isResolve bool) (err error) {
+ if comment.Type != CommentTypeCode {
+ return nil
+ }
+
+ if isResolve {
+ if comment.ResolveDoerID != 0 {
+ return nil
+ }
+
+ if _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", doer.ID, comment.ID); err != nil {
+ return err
+ }
+ } else {
+ if comment.ResolveDoerID == 0 {
+ return nil
+ }
+
+ if _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", 0, comment.ID); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// CanMarkConversation Add or remove Conversation mark for a code comment permission check
+// the PR writer , offfcial reviewer and poster can do it
+func CanMarkConversation(issue *Issue, doer *user_model.User) (permResult bool, err error) {
+ if doer == nil || issue == nil {
+ return false, fmt.Errorf("issue or doer is nil")
+ }
+
+ if doer.ID != issue.PosterID {
+ if err = issue.LoadRepo(db.DefaultContext); err != nil {
+ return false, err
+ }
+
+ p, err := access_model.GetUserRepoPermission(db.DefaultContext, issue.Repo, doer)
+ if err != nil {
+ return false, err
+ }
+
+ permResult = p.CanAccess(perm.AccessModeWrite, unit.TypePullRequests)
+ if !permResult {
+ if permResult, err = IsOfficialReviewer(db.DefaultContext, issue, doer); err != nil {
+ return false, err
+ }
+ }
+
+ if !permResult {
+ return false, nil
+ }
+ }
+
+ return true, nil
+}
+
+// DeleteReview delete a review and it's code comments
+func DeleteReview(r *Review) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ if r.ID == 0 {
+ return fmt.Errorf("review is not allowed to be 0")
+ }
+
+ if r.Type == ReviewTypeRequest {
+ return fmt.Errorf("review request can not be deleted using this method")
+ }
+
+ opts := FindCommentsOptions{
+ Type: CommentTypeCode,
+ IssueID: r.IssueID,
+ ReviewID: r.ID,
+ }
+
+ if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil {
+ return err
+ }
+
+ opts = FindCommentsOptions{
+ Type: CommentTypeReview,
+ IssueID: r.IssueID,
+ ReviewID: r.ID,
+ }
+
+ if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil {
+ return err
+ }
+
+ if _, err := sess.ID(r.ID).Delete(new(Review)); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// GetCodeCommentsCount return count of CodeComments a Review has
+func (r *Review) GetCodeCommentsCount() int {
+ opts := FindCommentsOptions{
+ Type: CommentTypeCode,
+ IssueID: r.IssueID,
+ ReviewID: r.ID,
+ }
+ conds := opts.toConds()
+ if r.ID == 0 {
+ conds = conds.And(builder.Eq{"invalidated": false})
+ }
+
+ count, err := db.GetEngine(db.DefaultContext).Where(conds).Count(new(Comment))
+ if err != nil {
+ return 0
+ }
+ return int(count)
+}
+
+// HTMLURL formats a URL-string to the related review issue-comment
+func (r *Review) HTMLURL() string {
+ opts := FindCommentsOptions{
+ Type: CommentTypeReview,
+ IssueID: r.IssueID,
+ ReviewID: r.ID,
+ }
+ comment := new(Comment)
+ has, err := db.GetEngine(db.DefaultContext).Where(opts.toConds()).Get(comment)
+ if err != nil || !has {
+ return ""
+ }
+ return comment.HTMLURL()
+}
+
+// RemapExternalUser ExternalUserRemappable interface
+func (r *Review) RemapExternalUser(externalName string, externalID, userID int64) error {
+ r.OriginalAuthor = externalName
+ r.OriginalAuthorID = externalID
+ r.ReviewerID = userID
+ return nil
+}
+
+// GetUserID ExternalUserRemappable interface
+func (r *Review) GetUserID() int64 { return r.ReviewerID }
+
+// GetExternalName ExternalUserRemappable interface
+func (r *Review) GetExternalName() string { return r.OriginalAuthor }
+
+// GetExternalID ExternalUserRemappable interface
+func (r *Review) GetExternalID() int64 { return r.OriginalAuthorID }
+
+// UpdateReviewsMigrationsByType updates reviews' migrations information via given git service type and original id and poster id
+func UpdateReviewsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error {
+ _, err := db.GetEngine(db.DefaultContext).Table("review").
+ Where("original_author_id = ?", originalAuthorID).
+ And(migratedIssueCond(tp)).
+ Update(map[string]interface{}{
+ "reviewer_id": posterID,
+ "original_author": "",
+ "original_author_id": 0,
+ })
+ return err
+}
diff --git a/models/issues/review_test.go b/models/issues/review_test.go
new file mode 100644
index 0000000000..3506604b46
--- /dev/null
+++ b/models/issues/review_test.go
@@ -0,0 +1,203 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetReviewByID(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ review, err := issues_model.GetReviewByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ assert.Equal(t, "Demo Review", review.Content)
+ assert.Equal(t, issues_model.ReviewTypeApprove, review.Type)
+
+ _, err = issues_model.GetReviewByID(db.DefaultContext, 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}).(*issues_model.Review)
+ assert.NoError(t, review.LoadAttributes(db.DefaultContext))
+ assert.NotNil(t, review.Issue)
+ assert.NotNil(t, review.Reviewer)
+
+ invalidReview1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 2}).(*issues_model.Review)
+ assert.Error(t, invalidReview1.LoadAttributes(db.DefaultContext))
+
+ invalidReview2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 3}).(*issues_model.Review)
+ assert.Error(t, invalidReview2.LoadAttributes(db.DefaultContext))
+}
+
+func TestReview_LoadCodeComments(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 4}).(*issues_model.Review)
+ assert.NoError(t, review.LoadAttributes(db.DefaultContext))
+ assert.NoError(t, review.LoadCodeComments(db.DefaultContext))
+ 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(db.DefaultContext, issues_model.FindReviewOptions{
+ Type: 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 TestGetCurrentReview(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}).(*issues_model.Issue)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
+
+ review, err := issues_model.GetCurrentReview(db.DefaultContext, 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}).(*user_model.User)
+ review2, err := issues_model.GetCurrentReview(db.DefaultContext, 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}).(*issues_model.Issue)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
+
+ review, err := issues_model.CreateReview(db.DefaultContext, 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}).(*issues_model.Issue)
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
+ user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}).(*user_model.User)
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}).(*user_model.User)
+
+ expectedReviews := []*issues_model.Review{}
+ expectedReviews = append(expectedReviews,
+ &issues_model.Review{
+ Reviewer: user3,
+ Type: issues_model.ReviewTypeReject,
+ UpdatedUnix: 946684812,
+ },
+ &issues_model.Review{
+ Reviewer: user4,
+ Type: issues_model.ReviewTypeApprove,
+ UpdatedUnix: 946684813,
+ },
+ &issues_model.Review{
+ Reviewer: user2,
+ Type: issues_model.ReviewTypeReject,
+ UpdatedUnix: 946684814,
+ })
+
+ allReviews, err := issues_model.GetReviewersByIssueID(issue.ID)
+ for _, reviewer := range allReviews {
+ assert.NoError(t, reviewer.LoadReviewer())
+ }
+ assert.NoError(t, err)
+ if assert.Len(t, allReviews, 3) {
+ for i, review := range allReviews {
+ 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}).(*issues_model.Review)
+ requestReviewExample := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ approveReviewExample := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 8}).(*issues_model.Review)
+ assert.False(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(rejectReviewExample, true))
+ rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9}).(*issues_model.Review)
+ requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ assert.True(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(requestReviewExample, true))
+ rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9}).(*issues_model.Review)
+ requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ assert.True(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(requestReviewExample, true))
+ rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9}).(*issues_model.Review)
+ requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ assert.True(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(requestReviewExample, false))
+ rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9}).(*issues_model.Review)
+ requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ assert.True(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(requestReviewExample, false))
+ rejectReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 9}).(*issues_model.Review)
+ requestReviewExample = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 11}).(*issues_model.Review)
+ assert.True(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(rejectReviewExample, false))
+ assert.False(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.False(t, approveReviewExample.Dismissed)
+
+ assert.NoError(t, issues_model.DismissReview(approveReviewExample, true))
+ assert.False(t, rejectReviewExample.Dismissed)
+ assert.False(t, requestReviewExample.Dismissed)
+ assert.True(t, approveReviewExample.Dismissed)
+}
diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go
new file mode 100644
index 0000000000..e7ac1314e9
--- /dev/null
+++ b/models/issues/stopwatch.go
@@ -0,0 +1,293 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ErrIssueStopwatchNotExist represents an error that stopwatch is not exist
+type ErrIssueStopwatchNotExist struct {
+ UserID int64
+ IssueID int64
+}
+
+func (err ErrIssueStopwatchNotExist) Error() string {
+ return fmt.Sprintf("issue stopwatch doesn't exist[uid: %d, issue_id: %d", err.UserID, err.IssueID)
+}
+
+// ErrIssueStopwatchAlreadyExist represents an error that stopwatch is already exist
+type ErrIssueStopwatchAlreadyExist struct {
+ UserID int64
+ IssueID int64
+}
+
+func (err ErrIssueStopwatchAlreadyExist) Error() string {
+ return fmt.Sprintf("issue stopwatch already exists[uid: %d, issue_id: %d", err.UserID, err.IssueID)
+}
+
+// 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)
+}
+
+// Duration returns a human-readable duration string based on local server time
+func (s Stopwatch) Duration() string {
+ return util.SecToTime(s.Seconds())
+}
+
+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
+}
+
+// UserIDCount is a simple coalition of UserID and Count
+type UserStopwatch struct {
+ UserID int64
+ StopWatches []*Stopwatch
+}
+
+// GetUIDsAndNotificationCounts between the two provided times
+func GetUIDsAndStopwatch() ([]*UserStopwatch, error) {
+ sws := []*Stopwatch{}
+ if err := db.GetEngine(db.DefaultContext).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 all stopwatches of a user
+func GetUserStopwatches(userID int64, listOptions db.ListOptions) ([]*Stopwatch, error) {
+ sws := make([]*Stopwatch, 0, 8)
+ sess := db.GetEngine(db.DefaultContext).Where("stopwatch.user_id = ?", userID)
+ if listOptions.Page != 0 {
+ sess = db.SetSessionPagination(sess, &listOptions)
+ }
+
+ err := sess.Find(&sws)
+ if err != nil {
+ return nil, err
+ }
+ return sws, nil
+}
+
+// CountUserStopwatches return count of all stopwatches of a user
+func CountUserStopwatches(userID int64) (int64, error) {
+ return db.GetEngine(db.DefaultContext).Where("user_id = ?", userID).Count(&Stopwatch{})
+}
+
+// StopwatchExists returns true if the stopwatch exists
+func StopwatchExists(userID, issueID int64) bool {
+ _, exists, _ := getStopwatch(db.DefaultContext, userID, issueID)
+ return exists
+}
+
+// HasUserStopwatch returns true if the user has a stopwatch
+func HasUserStopwatch(ctx context.Context, userID int64) (exists bool, sw *Stopwatch, err error) {
+ sw = new(Stopwatch)
+ exists, err = db.GetEngine(ctx).
+ Where("user_id = ?", userID).
+ Get(sw)
+ return
+}
+
+// FinishIssueStopwatchIfPossible if stopwatch exist then finish it otherwise ignore
+func FinishIssueStopwatchIfPossible(ctx context.Context, user *user_model.User, issue *Issue) error {
+ _, exists, err := getStopwatch(ctx, user.ID, issue.ID)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+ return FinishIssueStopwatch(ctx, user, issue)
+}
+
+// CreateOrStopIssueStopwatch create an issue stopwatch if it's not exist, otherwise finish it
+func CreateOrStopIssueStopwatch(user *user_model.User, issue *Issue) error {
+ _, exists, err := getStopwatch(db.DefaultContext, user.ID, issue.ID)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return FinishIssueStopwatch(db.DefaultContext, user, issue)
+ }
+ return CreateIssueStopwatch(db.DefaultContext, user, issue)
+}
+
+// FinishIssueStopwatch if stopwatch exist then finish it otherwise return an error
+func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error {
+ sw, exists, err := getStopwatch(ctx, user.ID, issue.ID)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return ErrIssueStopwatchNotExist{
+ UserID: user.ID,
+ IssueID: issue.ID,
+ }
+ }
+
+ // 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 := db.Insert(ctx, tt); err != nil {
+ return err
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Doer: user,
+ Issue: issue,
+ Repo: issue.Repo,
+ Content: util.SecToTime(timediff),
+ Type: CommentTypeStopTracking,
+ TimeID: tt.ID,
+ }); err != nil {
+ return err
+ }
+ _, err = db.DeleteByBean(ctx, sw)
+ return err
+}
+
+// CreateIssueStopwatch creates a stopwatch if not exist, otherwise return an error
+func CreateIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error {
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ // if another stopwatch is running: stop it
+ exists, sw, err := HasUserStopwatch(ctx, user.ID)
+ if err != nil {
+ return err
+ }
+ if exists {
+ issue, err := GetIssueByID(ctx, sw.IssueID)
+ if err != nil {
+ return err
+ }
+
+ if err := FinishIssueStopwatch(ctx, user, issue); err != nil {
+ return err
+ }
+ }
+
+ // Create stopwatch
+ sw = &Stopwatch{
+ UserID: user.ID,
+ IssueID: issue.ID,
+ }
+
+ if err := db.Insert(ctx, sw); err != nil {
+ return err
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Doer: user,
+ Issue: issue,
+ Repo: issue.Repo,
+ Type: CommentTypeStartTracking,
+ }); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CancelStopwatch removes the given stopwatch and logs it into issue's timeline.
+func CancelStopwatch(user *user_model.User, issue *Issue) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ if err := cancelStopwatch(ctx, user, issue); err != nil {
+ return err
+ }
+ return committer.Commit()
+}
+
+func cancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error {
+ e := db.GetEngine(ctx)
+ sw, exists, err := getStopwatch(ctx, user.ID, issue.ID)
+ if err != nil {
+ return err
+ }
+
+ if exists {
+ if _, err := e.Delete(sw); err != nil {
+ return err
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Doer: user,
+ Issue: issue,
+ Repo: issue.Repo,
+ Type: CommentTypeCancelTracking,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/models/issues/stopwatch_test.go b/models/issues/stopwatch_test.go
new file mode 100644
index 0000000000..c0573964d5
--- /dev/null
+++ b/models/issues/stopwatch_test.go
@@ -0,0 +1,79 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCancelStopwatch(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ user1, err := user_model.GetUserByID(1)
+ assert.NoError(t, err)
+
+ issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2)
+ assert.NoError(t, err)
+
+ err = issues_model.CancelStopwatch(user1, issue1)
+ assert.NoError(t, err)
+ 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})
+
+ assert.Nil(t, issues_model.CancelStopwatch(user1, issue2))
+}
+
+func TestStopwatchExists(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ assert.True(t, issues_model.StopwatchExists(1, 1))
+ assert.False(t, issues_model.StopwatchExists(1, 2))
+}
+
+func TestHasUserStopwatch(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ exists, sw, err := issues_model.HasUserStopwatch(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ assert.Equal(t, int64(1), sw.ID)
+
+ exists, _, err = issues_model.HasUserStopwatch(db.DefaultContext, 3)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestCreateOrStopIssueStopwatch(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ user2, err := user_model.GetUserByID(2)
+ assert.NoError(t, err)
+ user3, err := user_model.GetUserByID(3)
+ assert.NoError(t, err)
+
+ issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+ issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2)
+ assert.NoError(t, err)
+
+ assert.NoError(t, issues_model.CreateOrStopIssueStopwatch(user3, issue1))
+ sw := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: 3, IssueID: 1}).(*issues_model.Stopwatch)
+ assert.LessOrEqual(t, sw.CreatedUnix, timeutil.TimeStampNow())
+
+ assert.NoError(t, issues_model.CreateOrStopIssueStopwatch(user2, issue2))
+ unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: 2, IssueID: 2})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: 2, IssueID: 2})
+}
diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go
new file mode 100644
index 0000000000..54179bd3ab
--- /dev/null
+++ b/models/issues/tracked_time.go
@@ -0,0 +1,316 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+ "context"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/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() (err error) {
+ return t.loadAttributes(db.DefaultContext)
+}
+
+func (t *TrackedTime) loadAttributes(ctx context.Context) (err error) {
+ if t.Issue == nil {
+ t.Issue, err = GetIssueByID(ctx, t.IssueID)
+ if err != nil {
+ return
+ }
+ err = t.Issue.LoadRepo(ctx)
+ if err != nil {
+ return
+ }
+ }
+ if t.User == nil {
+ t.User, err = user_model.GetUserByIDCtx(ctx, t.UserID)
+ if err != nil {
+ return
+ }
+ }
+ return
+}
+
+// LoadAttributes load Issue, User
+func (tl TrackedTimeList) LoadAttributes() (err error) {
+ for _, t := range tl {
+ if err = t.LoadAttributes(); err != nil {
+ return err
+ }
+ }
+ return
+}
+
+// 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) toCond() 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
+}
+
+// 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.toCond())
+
+ if opts.Page != 0 {
+ sess = db.SetEnginePagination(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
+}
+
+// CountTrackedTimes returns count of tracked times that fit to the given options.
+func CountTrackedTimes(opts *FindTrackedTimesOptions) (int64, error) {
+ sess := db.GetEngine(db.DefaultContext).Where(opts.toCond())
+ 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(user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ 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 := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Issue: issue,
+ Repo: issue.Repo,
+ Doer: user,
+ Content: util.SecToTime(amount),
+ Type: CommentTypeAddTimeManual,
+ TimeID: t.ID,
+ }); err != nil {
+ return nil, err
+ }
+
+ return t, committer.Commit()
+}
+
+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)
+}
+
+// TotalTimes returns the spent time for each user by an issue
+func TotalTimes(options *FindTrackedTimesOptions) (map[*user_model.User]string, error) {
+ trackedTimes, err := GetTrackedTimes(db.DefaultContext, 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]string)
+ // Fetching User and making time human readable
+ for userID, total := range totalTimesByUser {
+ user, err := user_model.GetUserByID(userID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ continue
+ }
+ return nil, err
+ }
+ totalTimes[user] = util.SecToTime(total)
+ }
+ return totalTimes, nil
+}
+
+// DeleteIssueUserTimes deletes times for issue
+func DeleteIssueUserTimes(issue *Issue, user *user_model.User) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ opts := FindTrackedTimesOptions{
+ IssueID: issue.ID,
+ UserID: user.ID,
+ }
+
+ removedTime, err := deleteTimes(ctx, opts)
+ if err != nil {
+ return err
+ }
+ if removedTime == 0 {
+ return db.ErrNotExist{}
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ return err
+ }
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Issue: issue,
+ Repo: issue.Repo,
+ Doer: user,
+ Content: "- " + util.SecToTime(removedTime),
+ Type: CommentTypeDeleteTimeManual,
+ }); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// DeleteTime delete a specific Time
+func DeleteTime(t *TrackedTime) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := t.loadAttributes(ctx); err != nil {
+ return err
+ }
+
+ if err := deleteTime(ctx, t); err != nil {
+ return err
+ }
+
+ if _, err := CreateCommentCtx(ctx, &CreateCommentOptions{
+ Issue: t.Issue,
+ Repo: t.Issue.Repo,
+ Doer: t.User,
+ Content: "- " + util.SecToTime(t.Time),
+ Type: CommentTypeDeleteTimeManual,
+ }); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func deleteTimes(ctx context.Context, opts FindTrackedTimesOptions) (removedTime int64, err error) {
+ removedTime, err = GetTrackedSeconds(ctx, opts)
+ if err != nil || removedTime == 0 {
+ return
+ }
+
+ _, err = opts.toSession(db.GetEngine(ctx)).Table("tracked_time").Cols("deleted").Update(&TrackedTime{Deleted: true})
+ return
+}
+
+func deleteTime(ctx context.Context, t *TrackedTime) error {
+ if t.Deleted {
+ return db.ErrNotExist{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(id int64) (*TrackedTime, error) {
+ time := new(TrackedTime)
+ has, err := db.GetEngine(db.DefaultContext).ID(id).Get(time)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, db.ErrNotExist{ID: id}
+ }
+ return time, nil
+}
diff --git a/models/issues/tracked_time_test.go b/models/issues/tracked_time_test.go
new file mode 100644
index 0000000000..787ba9b701
--- /dev/null
+++ b/models/issues/tracked_time_test.go
@@ -0,0 +1,118 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues_test
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAddTime(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ user3, err := user_model.GetUserByID(3)
+ assert.NoError(t, err)
+
+ issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1)
+ assert.NoError(t, err)
+
+ // 3661 = 1h 1min 1s
+ trackedTime, err := issues_model.AddTime(user3, 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}).(*issues_model.TrackedTime)
+ assert.Equal(t, int64(3661), tt.Time)
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeAddTimeManual, PosterID: 3, IssueID: 1}).(*issues_model.Comment)
+ assert.Equal(t, comment.Content, "1 hour 1 minute")
+}
+
+func TestGetTrackedTimes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ // by Issue
+ times, err := issues_model.GetTrackedTimes(db.DefaultContext, &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(db.DefaultContext, &issues_model.FindTrackedTimesOptions{IssueID: -1})
+ assert.NoError(t, err)
+ assert.Len(t, times, 0)
+
+ // by User
+ times, err = issues_model.GetTrackedTimes(db.DefaultContext, &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(db.DefaultContext, &issues_model.FindTrackedTimesOptions{UserID: 3})
+ assert.NoError(t, err)
+ assert.Len(t, times, 0)
+
+ // by Repo
+ times, err = issues_model.GetTrackedTimes(db.DefaultContext, &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(db.DefaultContext, times[0].IssueID)
+ assert.NoError(t, err)
+ assert.Equal(t, issue.RepoID, int64(2))
+
+ times, err = issues_model.GetTrackedTimes(db.DefaultContext, &issues_model.FindTrackedTimesOptions{RepositoryID: 1})
+ assert.NoError(t, err)
+ assert.Len(t, times, 5)
+
+ times, err = issues_model.GetTrackedTimes(db.DefaultContext, &issues_model.FindTrackedTimesOptions{RepositoryID: 10})
+ assert.NoError(t, err)
+ assert.Len(t, times, 0)
+}
+
+func TestTotalTimes(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ total, err := issues_model.TotalTimes(&issues_model.FindTrackedTimesOptions{IssueID: 1})
+ assert.NoError(t, err)
+ assert.Len(t, total, 1)
+ for user, time := range total {
+ assert.Equal(t, int64(1), user.ID)
+ assert.Equal(t, "6 minutes 40 seconds", time)
+ }
+
+ total, err = issues_model.TotalTimes(&issues_model.FindTrackedTimesOptions{IssueID: 2})
+ assert.NoError(t, err)
+ assert.Len(t, total, 2)
+ for user, time := range total {
+ if user.ID == 2 {
+ assert.Equal(t, "1 hour 1 minute", time)
+ } else if user.ID == 1 {
+ assert.Equal(t, "20 seconds", time)
+ } else {
+ assert.Error(t, assert.AnError)
+ }
+ }
+
+ total, err = issues_model.TotalTimes(&issues_model.FindTrackedTimesOptions{IssueID: 5})
+ assert.NoError(t, err)
+ assert.Len(t, total, 1)
+ for user, time := range total {
+ assert.Equal(t, int64(2), user.ID)
+ assert.Equal(t, "1 second", time)
+ }
+
+ total, err = issues_model.TotalTimes(&issues_model.FindTrackedTimesOptions{IssueID: 4})
+ assert.NoError(t, err)
+ assert.Len(t, total, 2)
+}