diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2022-06-13 17:37:59 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-06-13 17:37:59 +0800 |
commit | 1a9821f57a0293db3adc0eab8aff08ca5fa1026c (patch) | |
tree | 3c3d02813eb63c0d0827ef6d9745f6dcdd2636cb /models/issues | |
parent | 3708ca8e2849ca7e36e6bd15ec6935a2a2d81e55 (diff) | |
download | gitea-1a9821f57a0293db3adc0eab8aff08ca5fa1026c.tar.gz gitea-1a9821f57a0293db3adc0eab8aff08ca5fa1026c.zip |
Move issues related files into models/issues (#19931)
* Move access and repo permission to models/perm/access
* fix test
* fix git test
* Move functions sequence
* Some improvements per @KN4CK3R and @delvh
* Move issues related code to models/issues
* Move some issues related sub package
* Merge
* Fix test
* Fix test
* Fix test
* Fix test
* Rename some files
Diffstat (limited to 'models/issues')
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) +} |