diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
commit | dd136858f1ea40ad3c94191d647487fa4f31926c (patch) | |
tree | 58fec94a7b2a12510c9664b21793f1ed560c6518 /services/issue | |
parent | Initial commit. (diff) | |
download | forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip |
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'services/issue')
-rw-r--r-- | services/issue/assignee.go | 314 | ||||
-rw-r--r-- | services/issue/assignee_test.go | 48 | ||||
-rw-r--r-- | services/issue/comments.go | 136 | ||||
-rw-r--r-- | services/issue/comments_test.go | 147 | ||||
-rw-r--r-- | services/issue/commit.go | 202 | ||||
-rw-r--r-- | services/issue/commit_test.go | 301 | ||||
-rw-r--r-- | services/issue/content.go | 25 | ||||
-rw-r--r-- | services/issue/issue.go | 349 | ||||
-rw-r--r-- | services/issue/issue_test.go | 87 | ||||
-rw-r--r-- | services/issue/label.go | 95 | ||||
-rw-r--r-- | services/issue/label_test.go | 62 | ||||
-rw-r--r-- | services/issue/main_test.go | 23 | ||||
-rw-r--r-- | services/issue/milestone.go | 110 | ||||
-rw-r--r-- | services/issue/milestone_test.go | 35 | ||||
-rw-r--r-- | services/issue/pull.go | 147 | ||||
-rw-r--r-- | services/issue/reaction.go | 47 | ||||
-rw-r--r-- | services/issue/status.go | 36 | ||||
-rw-r--r-- | services/issue/template.go | 193 |
18 files changed, 2357 insertions, 0 deletions
diff --git a/services/issue/assignee.go b/services/issue/assignee.go new file mode 100644 index 0000000..9c2ef74 --- /dev/null +++ b/services/issue/assignee.go @@ -0,0 +1,314 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + 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/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +// DeleteNotPassedAssignee deletes all assignees who aren't passed via the "assignees" array +func DeleteNotPassedAssignee(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, assignees []*user_model.User) (err error) { + var found bool + oriAssignes := make([]*user_model.User, len(issue.Assignees)) + _ = copy(oriAssignes, issue.Assignees) + + for _, assignee := range oriAssignes { + found = false + for _, alreadyAssignee := range assignees { + if assignee.ID == alreadyAssignee.ID { + found = true + break + } + } + + if !found { + // This function also does comments and hooks, which is why we call it separately instead of directly removing the assignees here + if _, _, err := ToggleAssigneeWithNotify(ctx, issue, doer, assignee.ID); err != nil { + return err + } + } + } + + return nil +} + +// ToggleAssigneeWithNoNotify changes a user between assigned and not assigned for this issue, and make issue comment for it. +func ToggleAssigneeWithNotify(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, assigneeID int64) (removed bool, comment *issues_model.Comment, err error) { + removed, comment, err = issues_model.ToggleIssueAssignee(ctx, issue, doer, assigneeID) + if err != nil { + return false, nil, err + } + + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + return false, nil, err + } + + notify_service.IssueChangeAssignee(ctx, doer, issue, assignee, removed, comment) + + return removed, comment, err +} + +// ReviewRequest add or remove a review request from a user for this PR, and make comment for it. +func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) { + if isAdd { + comment, err = issues_model.AddReviewRequest(ctx, issue, reviewer, doer) + } else { + comment, err = issues_model.RemoveReviewRequest(ctx, issue, reviewer, doer) + } + + if err != nil { + return nil, err + } + + if comment != nil { + notify_service.PullRequestReviewRequest(ctx, doer, issue, reviewer, isAdd, comment) + } + + return comment, err +} + +// IsValidReviewRequest Check permission for ReviewRequest +func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, isAdd bool, issue *issues_model.Issue, permDoer *access_model.Permission) error { + if reviewer.IsOrganization() { + return issues_model.ErrNotValidReviewRequest{ + Reason: "Organization can't be added as reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + if doer.IsOrganization() { + return issues_model.ErrNotValidReviewRequest{ + Reason: "Organization can't be doer to add reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + permReviewer, err := access_model.GetUserRepoPermission(ctx, issue.Repo, reviewer) + if err != nil { + return err + } + + if permDoer == nil { + permDoer = new(access_model.Permission) + *permDoer, err = access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + } + + lastreview, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID) + if err != nil && !issues_model.IsErrReviewNotExist(err) { + return err + } + + canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue) + + if isAdd { + if !permReviewer.CanAccessAny(perm.AccessModeRead, unit.TypePullRequests) { + return issues_model.ErrNotValidReviewRequest{ + Reason: "Reviewer can't read", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if reviewer.ID == issue.PosterID && issue.OriginalAuthorID == 0 { + return issues_model.ErrNotValidReviewRequest{ + Reason: "poster of pr can't be reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if canDoerChangeReviewRequests { + return nil + } + + if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != issues_model.ReviewTypeRequest { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't choose reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if canDoerChangeReviewRequests { + return nil + } + + if lastreview != nil && lastreview.Type == issues_model.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't remove reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } +} + +// IsValidTeamReviewRequest Check permission for ReviewRequest Team +func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, doer *user_model.User, isAdd bool, issue *issues_model.Issue) error { + if doer.IsOrganization() { + return issues_model.ErrNotValidReviewRequest{ + Reason: "Organization can't be doer to add reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue) + + if isAdd { + if issue.Repo.IsPrivate { + hasTeam := organization.HasTeamRepo(ctx, reviewer.OrgID, reviewer.ID, issue.RepoID) + + if !hasTeam { + return issues_model.ErrNotValidReviewRequest{ + Reason: "Reviewing team can't read repo", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + } + + if canDoerChangeReviewRequests { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't choose reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } + } + + if canDoerChangeReviewRequests { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't remove reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } +} + +// TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it. +func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool) (comment *issues_model.Comment, err error) { + if isAdd { + comment, err = issues_model.AddTeamReviewRequest(ctx, issue, reviewer, doer) + } else { + comment, err = issues_model.RemoveTeamReviewRequest(ctx, issue, reviewer, doer) + } + + if err != nil { + return nil, err + } + + if comment == nil || !isAdd { + return nil, nil + } + + return comment, teamReviewRequestNotify(ctx, issue, doer, reviewer, isAdd, comment) +} + +func ReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewNotifers []*ReviewRequestNotifier) { + for _, reviewNotifer := range reviewNotifers { + if reviewNotifer.Reviewer != nil { + notify_service.PullRequestReviewRequest(ctx, issue.Poster, issue, reviewNotifer.Reviewer, reviewNotifer.IsAdd, reviewNotifer.Comment) + } else if reviewNotifer.ReviewTeam != nil { + if err := teamReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifer.ReviewTeam, reviewNotifer.IsAdd, reviewNotifer.Comment); err != nil { + log.Error("teamReviewRequestNotify: %v", err) + } + } + } +} + +// teamReviewRequestNotify notify all user in this team +func teamReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool, comment *issues_model.Comment) error { + // notify all user in this team + if err := comment.LoadIssue(ctx); err != nil { + return err + } + + members, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + TeamID: reviewer.ID, + }) + if err != nil { + return err + } + + for _, member := range members { + if member.ID == comment.Issue.PosterID { + continue + } + comment.AssigneeID = member.ID + notify_service.PullRequestReviewRequest(ctx, doer, issue, member, isAdd, comment) + } + + return err +} + +// CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR +func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool { + // The poster of the PR can change the reviewers + if doer.ID == issue.PosterID { + return true + } + + // The owner of the repo can change the reviewers + if doer.ID == repo.OwnerID { + return true + } + + // Collaborators of the repo can change the reviewers + isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, doer.ID) + if err != nil { + log.Error("IsCollaborator: %v", err) + return false + } + if isCollaborator { + return true + } + + // If the repo's owner is an organization, members of teams with read permission on pull requests can change reviewers + if repo.Owner.IsOrganization() { + teams, err := organization.GetTeamsWithAccessToRepo(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead) + if err != nil { + log.Error("GetTeamsWithAccessToRepo: %v", err) + return false + } + for _, team := range teams { + if !team.UnitEnabled(ctx, unit.TypePullRequests) { + continue + } + isMember, err := organization.IsTeamMember(ctx, repo.OwnerID, team.ID, doer.ID) + if err != nil { + log.Error("IsTeamMember: %v", err) + continue + } + if isMember { + return true + } + } + } + + return false +} diff --git a/services/issue/assignee_test.go b/services/issue/assignee_test.go new file mode 100644 index 0000000..2b70b8c --- /dev/null +++ b/services/issue/assignee_test.go @@ -0,0 +1,48 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +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" + "github.com/stretchr/testify/require" +) + +func TestDeleteNotPassedAssignee(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Fake issue with assignees + issue, err := issues_model.GetIssueByID(db.DefaultContext, 1) + require.NoError(t, err) + + err = issue.LoadAttributes(db.DefaultContext) + require.NoError(t, err) + + assert.Len(t, issue.Assignees, 1) + + user1, err := user_model.GetUserByID(db.DefaultContext, 1) // This user is already assigned (see the definition in fixtures), so running UpdateAssignee should unassign him + require.NoError(t, err) + + // Check if he got removed + isAssigned, err := issues_model.IsUserAssignedToIssue(db.DefaultContext, issue, user1) + require.NoError(t, err) + assert.True(t, isAssigned) + + // Clean everyone + err = DeleteNotPassedAssignee(db.DefaultContext, issue, user1, []*user_model.User{}) + require.NoError(t, err) + assert.Empty(t, issue.Assignees) + + // Reload to check they're gone + issue.ResetAttributesLoaded() + require.NoError(t, issue.LoadAssignees(db.DefaultContext)) + assert.Empty(t, issue.Assignees) + assert.Empty(t, issue.Assignee) +} diff --git a/services/issue/comments.go b/services/issue/comments.go new file mode 100644 index 0000000..3ab577b --- /dev/null +++ b/services/issue/comments.go @@ -0,0 +1,136 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + notify_service "code.gitea.io/gitea/services/notify" +) + +// CreateRefComment creates a commit reference comment to issue. +func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.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(ctx).Get(&issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + IssueID: issue.ID, + CommitSHA: commitSHA, + }) + if err != nil { + return fmt.Errorf("check reference comment: %w", err) + } else if has { + return nil + } + + _, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeCommitRef, + Doer: doer, + Repo: repo, + Issue: issue, + CommitSHA: commitSHA, + Content: content, + }) + return err +} + +// CreateIssueComment creates a plain issue comment. +func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) { + // Check if doer is blocked by the poster of the issue or by the owner of the repository. + if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeComment, + Doer: doer, + Repo: repo, + Issue: issue, + Content: content, + Attachments: attachments, + }) + if err != nil { + return nil, err + } + + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content) + if err != nil { + return nil, err + } + + notify_service.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) + + return comment, nil +} + +// UpdateComment updates information of comment. +func UpdateComment(ctx context.Context, c *issues_model.Comment, contentVersion int, doer *user_model.User, oldContent string) error { + if err := c.LoadReview(ctx); err != nil { + return err + } + isPartOfPendingReview := c.Review != nil && c.Review.Type == issues_model.ReviewTypePending + + needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport() && !isPartOfPendingReview + if needsContentHistory { + hasContentHistory, err := issues_model.HasIssueContentHistory(ctx, c.IssueID, c.ID) + if err != nil { + return err + } + if !hasContentHistory { + if err = issues_model.SaveIssueContentHistory(ctx, c.PosterID, c.IssueID, c.ID, + c.CreatedUnix, oldContent, true); err != nil { + return err + } + } + } + + if err := issues_model.UpdateComment(ctx, c, contentVersion, doer); err != nil { + return err + } + + if needsContentHistory { + historyDate := timeutil.TimeStampNow() + if c.Issue.NoAutoTime { + historyDate = c.Issue.UpdatedUnix + } + err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, historyDate, c.Content, false) + if err != nil { + return err + } + } + + if !isPartOfPendingReview { + notify_service.UpdateComment(ctx, doer, c, oldContent) + } + + return nil +} + +// DeleteComment deletes the comment +func DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) error { + err := db.WithTx(ctx, func(ctx context.Context) error { + return issues_model.DeleteComment(ctx, comment) + }) + if err != nil { + return err + } + + if err := comment.LoadReview(ctx); err != nil { + return err + } + if comment.Review == nil || comment.Review.Type != issues_model.ReviewTypePending { + notify_service.DeleteComment(ctx, doer, comment) + } + + return nil +} diff --git a/services/issue/comments_test.go b/services/issue/comments_test.go new file mode 100644 index 0000000..62547a5 --- /dev/null +++ b/services/issue/comments_test.go @@ -0,0 +1,147 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue_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" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + issue_service "code.gitea.io/gitea/services/issue" + "code.gitea.io/gitea/tests" + + _ "code.gitea.io/gitea/services/webhook" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteComment(t *testing.T) { + // Use the webhook notification to check if a notification is fired for an action. + defer test.MockVariableValue(&setting.DisableWebhooks, false)() + require.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("Normal comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + unittest.AssertCount(t, &issues_model.Reaction{CommentID: comment.ID}, 2) + + require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, &webhook_model.Webhook{ + RepoID: issue.RepoID, + IsActive: true, + Events: `{"choose_events":true,"events":{"issue_comment": true}}`, + })) + hookTaskCount := unittest.GetCount(t, &webhook_model.HookTask{}) + + require.NoError(t, issue_service.DeleteComment(db.DefaultContext, nil, comment)) + + // The comment doesn't exist anymore. + unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID}) + // Reactions don't exist anymore for this comment. + unittest.AssertNotExistsBean(t, &issues_model.Reaction{CommentID: comment.ID}) + // Number of comments was decreased. + assert.EqualValues(t, issue.NumComments-1, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}).NumComments) + // A notification was fired for the deletion of this comment. + assert.EqualValues(t, hookTaskCount+1, unittest.GetCount(t, &webhook_model.HookTask{})) + }) + + t.Run("Comment of pending review", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // We have to ensure that this comment's linked review is pending. + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4}, "review_id != 0") + review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}) + assert.EqualValues(t, issues_model.ReviewTypePending, review.Type) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + + require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, &webhook_model.Webhook{ + RepoID: issue.RepoID, + IsActive: true, + Events: `{"choose_events":true,"events":{"issue_comment": true}}`, + })) + hookTaskCount := unittest.GetCount(t, &webhook_model.HookTask{}) + + require.NoError(t, issue_service.DeleteComment(db.DefaultContext, nil, comment)) + + // The comment doesn't exist anymore. + unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID}) + // Ensure that the number of comments wasn't decreased. + assert.EqualValues(t, issue.NumComments, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}).NumComments) + // No notification was fired for the deletion of this comment. + assert.EqualValues(t, hookTaskCount, unittest.GetCount(t, &webhook_model.HookTask{})) + }) +} + +func TestUpdateComment(t *testing.T) { + // Use the webhook notification to check if a notification is fired for an action. + defer test.MockVariableValue(&setting.DisableWebhooks, false)() + require.NoError(t, unittest.PrepareTestDatabase()) + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + t.Run("Normal comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + unittest.AssertNotExistsBean(t, &issues_model.ContentHistory{CommentID: comment.ID}) + require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, &webhook_model.Webhook{ + RepoID: issue.RepoID, + IsActive: true, + Events: `{"choose_events":true,"events":{"issue_comment": true}}`, + })) + hookTaskCount := unittest.GetCount(t, &webhook_model.HookTask{}) + oldContent := comment.Content + comment.Content = "Hello!" + + require.NoError(t, issue_service.UpdateComment(db.DefaultContext, comment, 1, admin, oldContent)) + + newComment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + // Content was updated. + assert.EqualValues(t, comment.Content, newComment.Content) + // Content version was updated. + assert.EqualValues(t, 2, newComment.ContentVersion) + // A notification was fired for the update of this comment. + assert.EqualValues(t, hookTaskCount+1, unittest.GetCount(t, &webhook_model.HookTask{})) + // Issue history was saved for this comment. + unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{CommentID: comment.ID, IsFirstCreated: true, ContentText: oldContent}) + unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{CommentID: comment.ID, ContentText: comment.Content}, "is_first_created = false") + }) + + t.Run("Comment of pending review", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4}, "review_id != 0") + review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}) + assert.EqualValues(t, issues_model.ReviewTypePending, review.Type) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + unittest.AssertNotExistsBean(t, &issues_model.ContentHistory{CommentID: comment.ID}) + require.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, &webhook_model.Webhook{ + RepoID: issue.RepoID, + IsActive: true, + Events: `{"choose_events":true,"events":{"issue_comment": true}}`, + })) + hookTaskCount := unittest.GetCount(t, &webhook_model.HookTask{}) + oldContent := comment.Content + comment.Content = "Hello!" + + require.NoError(t, issue_service.UpdateComment(db.DefaultContext, comment, 1, admin, oldContent)) + + newComment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + // Content was updated. + assert.EqualValues(t, comment.Content, newComment.Content) + // Content version was updated. + assert.EqualValues(t, 2, newComment.ContentVersion) + // No notification was fired for the update of this comment. + assert.EqualValues(t, hookTaskCount, unittest.GetCount(t, &webhook_model.HookTask{})) + // Issue history was not saved for this comment. + unittest.AssertNotExistsBean(t, &issues_model.ContentHistory{CommentID: comment.ID}) + }) +} diff --git a/services/issue/commit.go b/services/issue/commit.go new file mode 100644 index 0000000..8b927d5 --- /dev/null +++ b/services/issue/commit.go @@ -0,0 +1,202 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + "html" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + 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/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/repository" +) + +const ( + secondsByMinute = float64(time.Minute / time.Second) // seconds in a minute + secondsByHour = 60 * secondsByMinute // seconds in an hour + secondsByDay = 8 * secondsByHour // seconds in a day + secondsByWeek = 5 * secondsByDay // seconds in a week + secondsByMonth = 4 * secondsByWeek // seconds in a month +) + +var reDuration = regexp.MustCompile(`(?i)^(?:(\d+([\.,]\d+)?)(?:mo))?(?:(\d+([\.,]\d+)?)(?:w))?(?:(\d+([\.,]\d+)?)(?:d))?(?:(\d+([\.,]\d+)?)(?:h))?(?:(\d+([\.,]\d+)?)(?:m))?$`) + +// timeLogToAmount parses time log string and returns amount in seconds +func timeLogToAmount(str string) int64 { + matches := reDuration.FindAllStringSubmatch(str, -1) + if len(matches) == 0 { + return 0 + } + + match := matches[0] + + var a int64 + + // months + if len(match[1]) > 0 { + mo, _ := strconv.ParseFloat(strings.Replace(match[1], ",", ".", 1), 64) + a += int64(mo * secondsByMonth) + } + + // weeks + if len(match[3]) > 0 { + w, _ := strconv.ParseFloat(strings.Replace(match[3], ",", ".", 1), 64) + a += int64(w * secondsByWeek) + } + + // days + if len(match[5]) > 0 { + d, _ := strconv.ParseFloat(strings.Replace(match[5], ",", ".", 1), 64) + a += int64(d * secondsByDay) + } + + // hours + if len(match[7]) > 0 { + h, _ := strconv.ParseFloat(strings.Replace(match[7], ",", ".", 1), 64) + a += int64(h * secondsByHour) + } + + // minutes + if len(match[9]) > 0 { + d, _ := strconv.ParseFloat(strings.Replace(match[9], ",", ".", 1), 64) + a += int64(d * secondsByMinute) + } + + return a +} + +func issueAddTime(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, time time.Time, timeLog string) error { + amount := timeLogToAmount(timeLog) + if amount == 0 { + return nil + } + + _, err := issues_model.AddTime(ctx, doer, issue, amount, time) + return err +} + +// getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue +// if the provided ref references a non-existent issue. +func getIssueFromRef(ctx context.Context, repo *repo_model.Repository, index int64) (*issues_model.Issue, error) { + issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, index) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + return nil, nil + } + return nil, err + } + return issue, nil +} + +// UpdateIssuesCommit checks if issues are manipulated by commit message. +func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commits []*repository.PushCommit, branchName string) error { + // Commits are appended in the reverse order. + for i := len(commits) - 1; i >= 0; i-- { + c := commits[i] + + type markKey struct { + ID int64 + Action references.XRefAction + } + + refMarked := make(container.Set[markKey]) + var refRepo *repo_model.Repository + var refIssue *issues_model.Issue + var err error + for _, ref := range references.FindAllIssueReferences(c.Message) { + // issue is from another repo + if len(ref.Owner) > 0 && len(ref.Name) > 0 { + refRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Name) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + log.Warn("Repository referenced in commit but does not exist: %v", err) + } else { + log.Error("repo_model.GetRepositoryByOwnerAndName: %v", err) + } + continue + } + } else { + refRepo = repo + } + if refIssue, err = getIssueFromRef(ctx, refRepo, ref.Index); err != nil { + return err + } + if refIssue == nil { + continue + } + + perm, err := access_model.GetUserRepoPermission(ctx, refRepo, doer) + if err != nil { + return err + } + + key := markKey{ID: refIssue.ID, Action: ref.Action} + if !refMarked.Add(key) { + continue + } + + // FIXME: this kind of condition is all over the code, it should be consolidated in a single place + canclose := perm.IsAdmin() || perm.IsOwner() || perm.CanWriteIssuesOrPulls(refIssue.IsPull) || refIssue.PosterID == doer.ID + cancomment := canclose || perm.CanReadIssuesOrPulls(refIssue.IsPull) + + // Don't proceed if the user can't comment + if !cancomment { + continue + } + + message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, html.EscapeString(repo.Link()), html.EscapeString(url.PathEscape(c.Sha1)), html.EscapeString(strings.SplitN(c.Message, "\n", 2)[0])) + if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil { + return err + } + + // Only issues can be closed/reopened this way, and user needs the correct permissions + if refIssue.IsPull || !canclose { + continue + } + + // Only process closing/reopening keywords + if ref.Action != references.XRefActionCloses && ref.Action != references.XRefActionReopens { + continue + } + + if !repo.CloseIssuesViaCommitInAnyBranch { + // If the issue was specified to be in a particular branch, don't allow commits in other branches to close it + if refIssue.Ref != "" { + issueBranchName := strings.TrimPrefix(refIssue.Ref, git.BranchPrefix) + if branchName != issueBranchName { + continue + } + // Otherwise, only process commits to the default branch + } else if branchName != repo.DefaultBranch { + continue + } + } + isClosed := ref.Action == references.XRefActionCloses + if isClosed && len(ref.TimeLog) > 0 { + if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil { + return err + } + } + if isClosed != refIssue.IsClosed { + refIssue.Repo = refRepo + if err := ChangeStatus(ctx, refIssue, doer, c.Sha1, isClosed); err != nil { + return err + } + } + } + } + return nil +} diff --git a/services/issue/commit_test.go b/services/issue/commit_test.go new file mode 100644 index 0000000..c3c3e4c --- /dev/null +++ b/services/issue/commit_test.go @@ -0,0 +1,301 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "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/repository" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/require" +) + +func TestUpdateIssuesCommit(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef1", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user4@example.com", + AuthorName: "User Four", + Message: "start working on #FST-1, #1", + }, + { + Sha1: "abcdef2", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "a plain message", + }, + { + Sha1: "abcdef2", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "close #2", + }, + } + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo.Owner = user + + commentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef1", + PosterID: user.ID, + IssueID: 1, + } + issueBean := &issues_model.Issue{RepoID: repo.ID, Index: 4} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 2}, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) + + // Test that push to a non-default branch closes no issue. + pushCommits = []*repository.PushCommit{ + { + Sha1: "abcdef1", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user4@example.com", + AuthorName: "User Four", + Message: "close #1", + }, + } + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + commentBean = &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef1", + PosterID: user.ID, + IssueID: 6, + } + issueBean = &issues_model.Issue{RepoID: repo.ID, Index: 1} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 1}, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, "non-existing-branch")) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) + + pushCommits = []*repository.PushCommit{ + { + Sha1: "abcdef3", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "close " + setting.AppURL + repo.FullName() + "/pulls/1", + }, + } + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + commentBean = &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef3", + PosterID: user.ID, + IssueID: 6, + } + issueBean = &issues_model.Issue{RepoID: repo.ID, Index: 1} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 1}, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} + +func TestUpdateIssuesCommit_Colon(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef2", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "close: #2", + }, + } + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo.Owner = user + + issueBean := &issues_model.Issue{RepoID: repo.ID, Index: 4} + + unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: repo.ID, Index: 2}, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} + +func TestUpdateIssuesCommit_Issue5957(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Test that push to a non-default branch closes an issue. + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef1", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user4@example.com", + AuthorName: "User Four", + Message: "close #2", + }, + } + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + commentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef1", + PosterID: user.ID, + IssueID: 7, + } + + issueBean := &issues_model.Issue{RepoID: repo.ID, Index: 2, ID: 7} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, "non-existing-branch")) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} + +func TestUpdateIssuesCommit_AnotherRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Test that a push to default branch closes issue in another repo + // If the user also has push permissions to that repo + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef1", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "close user2/repo1#1", + }, + } + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + commentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef1", + PosterID: user.ID, + IssueID: 1, + } + + issueBean := &issues_model.Issue{RepoID: 1, Index: 1, ID: 1} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} + +func TestUpdateIssuesCommit_AnotherRepo_FullAddress(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Test that a push to default branch closes issue in another repo + // If the user also has push permissions to that repo + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef1", + CommitterEmail: "user2@example.com", + CommitterName: "User Two", + AuthorEmail: "user2@example.com", + AuthorName: "User Two", + Message: "close " + setting.AppURL + "user2/repo1/issues/1", + }, + } + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + commentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef1", + PosterID: user.ID, + IssueID: 1, + } + + issueBean := &issues_model.Issue{RepoID: 1, Index: 1, ID: 1} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertExistsAndLoadBean(t, commentBean) + unittest.AssertExistsAndLoadBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} + +func TestUpdateIssuesCommit_AnotherRepoNoPermission(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + + // Test that a push with close reference *can not* close issue + // If the committer doesn't have push rights in that repo + pushCommits := []*repository.PushCommit{ + { + Sha1: "abcdef3", + CommitterEmail: "user10@example.com", + CommitterName: "User Ten", + AuthorEmail: "user10@example.com", + AuthorName: "User Ten", + Message: "close org3/repo3#1", + }, + { + Sha1: "abcdef4", + CommitterEmail: "user10@example.com", + CommitterName: "User Ten", + AuthorEmail: "user10@example.com", + AuthorName: "User Ten", + Message: "close " + setting.AppURL + "org3/repo3/issues/1", + }, + } + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 6}) + commentBean := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef3", + PosterID: user.ID, + IssueID: 6, + } + commentBean2 := &issues_model.Comment{ + Type: issues_model.CommentTypeCommitRef, + CommitSHA: "abcdef4", + PosterID: user.ID, + IssueID: 6, + } + + issueBean := &issues_model.Issue{RepoID: 3, Index: 1, ID: 6} + + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, commentBean2) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + require.NoError(t, UpdateIssuesCommit(db.DefaultContext, user, repo, pushCommits, repo.DefaultBranch)) + unittest.AssertNotExistsBean(t, commentBean) + unittest.AssertNotExistsBean(t, commentBean2) + unittest.AssertNotExistsBean(t, issueBean, "is_closed=1") + unittest.CheckConsistencyFor(t, &activities_model.Action{}) +} diff --git a/services/issue/content.go b/services/issue/content.go new file mode 100644 index 0000000..612a9a6 --- /dev/null +++ b/services/issue/content.go @@ -0,0 +1,25 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + notify_service "code.gitea.io/gitea/services/notify" +) + +// ChangeContent changes issue content, as the given user. +func ChangeContent(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, contentVersion int) (err error) { + oldContent := issue.Content + + if err := issues_model.ChangeIssueContent(ctx, issue, doer, content, contentVersion); err != nil { + return err + } + + notify_service.IssueChangeContent(ctx, doer, issue, oldContent) + + return nil +} diff --git a/services/issue/issue.go b/services/issue/issue.go new file mode 100644 index 0000000..5e72617 --- /dev/null +++ b/services/issue/issue.go @@ -0,0 +1,349 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + 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" + system_model "code.gitea.io/gitea/models/system" + 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/storage" + "code.gitea.io/gitea/modules/timeutil" + notify_service "code.gitea.io/gitea/services/notify" +) + +// NewIssue creates new issue with labels for repository. +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { + // Check if the user is not blocked by the repo's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) { + return user_model.ErrBlockedByUser + } + + if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { + return err + } + + for _, assigneeID := range assigneeIDs { + if _, err := AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, true); err != nil { + return err + } + } + + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content) + if err != nil { + return err + } + + notify_service.NewIssue(ctx, issue, mentions) + if len(issue.Labels) > 0 { + notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) + } + if issue.Milestone != nil { + notify_service.IssueChangeMilestone(ctx, issue.Poster, issue, 0) + } + + return nil +} + +// ChangeTitle changes the title of this issue, as the given user. +func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, title string) error { + oldTitle := issue.Title + issue.Title = title + + if oldTitle == title { + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return user_model.ErrBlockedByUser + } + + if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil { + return err + } + + var reviewNotifers []*ReviewRequestNotifier + if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) { + var err error + reviewNotifers, err = PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest) + if err != nil { + log.Error("PullRequestCodeOwnersReview: %v", err) + } + } + + notify_service.IssueChangeTitle(ctx, doer, issue, oldTitle) + ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifers) + + return nil +} + +// ChangeIssueRef changes the branch of this issue, as the given user. +func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error { + oldRef := issue.Ref + issue.Ref = ref + + if err := issues_model.ChangeIssueRef(ctx, issue, doer, oldRef); err != nil { + return err + } + + notify_service.IssueChangeRef(ctx, doer, issue, oldRef) + + return nil +} + +// UpdateAssignees is a helper function to add or delete one or multiple issue assignee(s) +// Deleting is done the GitHub way (quote from their api documentation): +// https://developer.github.com/v3/issues/#edit-an-issue +// "assignees" (array): Logins for Users to assign to this issue. +// Pass one or more user logins to replace the set of assignees on this Issue. +// Send an empty array ([]) to clear all assignees from the Issue. +func UpdateAssignees(ctx context.Context, issue *issues_model.Issue, oneAssignee string, multipleAssignees []string, doer *user_model.User) (err error) { + var allNewAssignees []*user_model.User + + // Keep the old assignee thingy for compatibility reasons + if oneAssignee != "" { + // Prevent double adding assignees + var isDouble bool + for _, assignee := range multipleAssignees { + if assignee == oneAssignee { + isDouble = true + break + } + } + + if !isDouble { + multipleAssignees = append(multipleAssignees, oneAssignee) + } + } + + // Loop through all assignees to add them + for _, assigneeName := range multipleAssignees { + assignee, err := user_model.GetUserByName(ctx, assigneeName) + if err != nil { + return err + } + + allNewAssignees = append(allNewAssignees, assignee) + } + + // Delete all old assignees not passed + if err = DeleteNotPassedAssignee(ctx, issue, doer, allNewAssignees); err != nil { + return err + } + + // Add all new assignees + // Update the assignee. The function will check if the user exists, is already + // assigned (which he shouldn't as we deleted all assignees before) and + // has access to the repo. + for _, assignee := range allNewAssignees { + // Extra method to prevent double adding (which would result in removing) + _, err = AddAssigneeIfNotAssigned(ctx, issue, doer, assignee.ID, true) + if err != nil { + return err + } + } + + return err +} + +// DeleteIssue deletes an issue +func DeleteIssue(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue) error { + // load issue before deleting it + if err := issue.LoadAttributes(ctx); err != nil { + return err + } + if err := issue.LoadPullRequest(ctx); err != nil { + return err + } + + // delete entries in database + if err := deleteIssue(ctx, issue); err != nil { + return err + } + + // delete pull request related git data + if issue.IsPull && gitRepo != nil { + if err := gitRepo.RemoveReference(fmt.Sprintf("%s%d/head", git.PullPrefix, issue.PullRequest.Index)); err != nil { + return err + } + } + + // If the Issue is pinned, we should unpin it before deletion to avoid problems with other pinned Issues + if issue.IsPinned() { + if err := issue.Unpin(ctx, doer); err != nil { + return err + } + } + + notify_service.DeleteIssue(ctx, doer, issue) + + return nil +} + +// AddAssigneeIfNotAssigned adds an assignee only if he isn't already assigned to the issue. +// Also checks for access of assigned user +func AddAssigneeIfNotAssigned(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, assigneeID int64, notify bool) (comment *issues_model.Comment, err error) { + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + return nil, err + } + + // Check if the user is already assigned + isAssigned, err := issues_model.IsUserAssignedToIssue(ctx, issue, assignee) + if err != nil { + return nil, err + } + if isAssigned { + // nothing to to + return nil, nil + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, issue.Repo, issue.IsPull) + if err != nil { + return nil, err + } + if !valid { + return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: assigneeID, RepoName: issue.Repo.Name} + } + + if notify { + _, comment, err = ToggleAssigneeWithNotify(ctx, issue, doer, assigneeID) + return comment, err + } + _, comment, err = issues_model.ToggleIssueAssignee(ctx, issue, doer, assigneeID) + return comment, err +} + +// GetRefEndNamesAndURLs retrieves the ref end names (e.g. refs/heads/branch-name -> branch-name) +// and their respective URLs. +func GetRefEndNamesAndURLs(issues []*issues_model.Issue, repoLink string) (map[int64]string, map[int64]string) { + issueRefEndNames := make(map[int64]string, len(issues)) + issueRefURLs := make(map[int64]string, len(issues)) + for _, issue := range issues { + if issue.Ref != "" { + issueRefEndNames[issue.ID] = git.RefName(issue.Ref).ShortName() + issueRefURLs[issue.ID] = git.RefURL(repoLink, issue.Ref) + } + } + return issueRefEndNames, issueRefURLs +} + +// deleteIssue deletes the issue +func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + e := db.GetEngine(ctx) + if _, err := e.ID(issue.ID).NoAutoCondition().Delete(issue); err != nil { + return err + } + + // update the total issue numbers + if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil { + return err + } + // if the issue is closed, update the closed issue numbers + if issue.IsClosed { + if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, true); err != nil { + return err + } + } + + if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + return fmt.Errorf("error updating counters for milestone id %d: %w", + issue.MilestoneID, err) + } + + if err := activities_model.DeleteIssueActions(ctx, issue.RepoID, issue.ID, issue.Index); err != nil { + return err + } + + // find attachments related to this issue and remove them + if err := issue.LoadAttributes(ctx); err != nil { + return err + } + + for i := range issue.Attachments { + system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", issue.Attachments[i].RelativePath()) + } + + // delete all database data still assigned to this issue + if err := db.DeleteBeans(ctx, + &issues_model.ContentHistory{IssueID: issue.ID}, + &issues_model.Comment{IssueID: issue.ID}, + &issues_model.IssueLabel{IssueID: issue.ID}, + &issues_model.IssueDependency{IssueID: issue.ID}, + &issues_model.IssueAssignees{IssueID: issue.ID}, + &issues_model.IssueUser{IssueID: issue.ID}, + &activities_model.Notification{IssueID: issue.ID}, + &issues_model.Reaction{IssueID: issue.ID}, + &issues_model.IssueWatch{IssueID: issue.ID}, + &issues_model.Stopwatch{IssueID: issue.ID}, + &issues_model.TrackedTime{IssueID: issue.ID}, + &project_model.ProjectIssue{IssueID: issue.ID}, + &repo_model.Attachment{IssueID: issue.ID}, + &issues_model.PullRequest{IssueID: issue.ID}, + &issues_model.Comment{RefIssueID: issue.ID}, + &issues_model.IssueDependency{DependencyID: issue.ID}, + &issues_model.Comment{DependentIssueID: issue.ID}, + ); err != nil { + return err + } + + return committer.Commit() +} + +// Set the UpdatedUnix date and the NoAutoTime field of an Issue if a non +// nil 'updated' time is provided +// +// In order to set a specific update time, the DB will be updated with +// NoAutoTime(). A 'NoAutoTime' boolean field in the Issue struct is used to +// propagate down to the DB update calls the will to apply autoupdate or not. +func SetIssueUpdateDate(ctx context.Context, issue *issues_model.Issue, updated *time.Time, doer *user_model.User) error { + issue.NoAutoTime = false + if updated == nil { + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + // Check if the poster is allowed to set an update date + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.IsAdmin() && !perm.IsOwner() { + return fmt.Errorf("user needs to have admin or owner right") + } + + // A simple guard against potential inconsistent calls + updatedUnix := timeutil.TimeStamp(updated.Unix()) + if updatedUnix < issue.CreatedUnix || updatedUnix > timeutil.TimeStampNow() { + return fmt.Errorf("unallowed update date") + } + + issue.UpdatedUnix = updatedUnix + issue.NoAutoTime = true + + return nil +} diff --git a/services/issue/issue_test.go b/services/issue/issue_test.go new file mode 100644 index 0000000..a0bb88e --- /dev/null +++ b/services/issue/issue_test.go @@ -0,0 +1,87 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +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" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetRefEndNamesAndURLs(t *testing.T) { + issues := []*issues_model.Issue{ + {ID: 1, Ref: "refs/heads/branch1"}, + {ID: 2, Ref: "refs/tags/tag1"}, + {ID: 3, Ref: "c0ffee"}, + } + repoLink := "/foo/bar" + + endNames, urls := GetRefEndNamesAndURLs(issues, repoLink) + assert.EqualValues(t, map[int64]string{1: "branch1", 2: "tag1", 3: "c0ffee"}, endNames) + assert.EqualValues(t, map[int64]string{ + 1: repoLink + "/src/branch/branch1", + 2: repoLink + "/src/tag/tag1", + 3: repoLink + "/src/commit/c0ffee", + }, urls) +} + +func TestIssue_DeleteIssue(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + issueIDs, err := issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1) + require.NoError(t, err) + assert.Len(t, issueIDs, 5) + + issue := &issues_model.Issue{ + RepoID: 1, + ID: issueIDs[2], + } + + err = deleteIssue(db.DefaultContext, issue) + require.NoError(t, err) + issueIDs, err = issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1) + require.NoError(t, err) + assert.Len(t, issueIDs, 4) + + // check attachment removal + attachments, err := repo_model.GetAttachmentsByIssueID(db.DefaultContext, 4) + require.NoError(t, err) + issue, err = issues_model.GetIssueByID(db.DefaultContext, 4) + require.NoError(t, err) + err = deleteIssue(db.DefaultContext, issue) + require.NoError(t, err) + assert.Len(t, attachments, 2) + for i := range attachments { + attachment, err := repo_model.GetAttachmentByUUID(db.DefaultContext, attachments[i].UUID) + require.Error(t, err) + assert.True(t, repo_model.IsErrAttachmentNotExist(err)) + assert.Nil(t, attachment) + } + + // check issue dependencies + user, err := user_model.GetUserByID(db.DefaultContext, 1) + require.NoError(t, err) + issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1) + require.NoError(t, err) + issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2) + require.NoError(t, err) + err = issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2) + require.NoError(t, err) + left, err := issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1) + require.NoError(t, err) + assert.False(t, left) + + err = deleteIssue(db.DefaultContext, issue2) + require.NoError(t, err) + left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1) + require.NoError(t, err) + assert.True(t, left) +} diff --git a/services/issue/label.go b/services/issue/label.go new file mode 100644 index 0000000..6b8070d --- /dev/null +++ b/services/issue/label.go @@ -0,0 +1,95 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + notify_service "code.gitea.io/gitea/services/notify" +) + +// ClearLabels clears all of an issue's labels +func ClearLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User) error { + if err := issues_model.ClearIssueLabels(ctx, issue, doer); err != nil { + return err + } + + notify_service.IssueClearLabels(ctx, doer, issue) + + return nil +} + +// AddLabel adds a new label to the issue. +func AddLabel(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { + if err := issues_model.NewIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + + notify_service.IssueChangeLabels(ctx, doer, issue, []*issues_model.Label{label}, nil) + return nil +} + +// AddLabels adds a list of new labels to the issue. +func AddLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { + if err := issues_model.NewIssueLabels(ctx, issue, labels, doer); err != nil { + return err + } + + notify_service.IssueChangeLabels(ctx, doer, issue, labels, nil) + return nil +} + +// RemoveLabel removes a label from issue by given ID. +func RemoveLabel(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { + dbCtx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err := issue.LoadRepo(dbCtx); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(dbCtx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.CanWriteIssuesOrPulls(issue.IsPull) { + if label.OrgID > 0 { + return issues_model.ErrOrgLabelNotExist{} + } + return issues_model.ErrRepoLabelNotExist{} + } + + if err := issues_model.DeleteIssueLabel(dbCtx, issue, label, doer); err != nil { + return err + } + + if err := committer.Commit(); err != nil { + return err + } + + notify_service.IssueChangeLabels(ctx, doer, issue, nil, []*issues_model.Label{label}) + return nil +} + +// ReplaceLabels removes all current labels and add new labels to the issue. +func ReplaceLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { + old, err := issues_model.GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + return err + } + + if err := issues_model.ReplaceIssueLabels(ctx, issue, labels, doer); err != nil { + return err + } + + notify_service.IssueChangeLabels(ctx, doer, issue, labels, old) + return nil +} diff --git a/services/issue/label_test.go b/services/issue/label_test.go new file mode 100644 index 0000000..b9d2634 --- /dev/null +++ b/services/issue/label_test.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +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/require" +) + +func TestIssue_AddLabels(t *testing.T) { + tests := []struct { + issueID int64 + labelIDs []int64 + doerID int64 + }{ + {1, []int64{1, 2}, 2}, // non-pull-request + {1, []int64{}, 2}, // non-pull-request, empty + {2, []int64{1, 2}, 2}, // pull-request + {2, []int64{}, 1}, // pull-request, empty + } + for _, test := range tests { + require.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: test.issueID}) + labels := make([]*issues_model.Label, len(test.labelIDs)) + for i, labelID := range test.labelIDs { + labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID}) + } + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID}) + require.NoError(t, AddLabels(db.DefaultContext, issue, doer, labels)) + for _, labelID := range test.labelIDs { + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: labelID}) + } + } +} + +func TestIssue_AddLabel(t *testing.T) { + tests := []struct { + issueID int64 + labelID int64 + doerID int64 + }{ + {1, 2, 2}, // non-pull-request, not-already-added label + {1, 1, 2}, // non-pull-request, already-added label + {2, 2, 2}, // pull-request, not-already-added label + {2, 1, 2}, // pull-request, already-added label + } + for _, test := range tests { + require.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: test.issueID}) + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: test.labelID}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID}) + require.NoError(t, AddLabel(db.DefaultContext, issue, doer, label)) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: test.labelID}) + } +} diff --git a/services/issue/main_test.go b/services/issue/main_test.go new file mode 100644 index 0000000..c3da441 --- /dev/null +++ b/services/issue/main_test.go @@ -0,0 +1,23 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/webhook" + + _ "code.gitea.io/gitea/models/actions" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + SetUp: func() error { + setting.LoadQueueSettings() + return webhook.Init() + }, + }) +} diff --git a/services/issue/milestone.go b/services/issue/milestone.go new file mode 100644 index 0000000..31490c7 --- /dev/null +++ b/services/issue/milestone.go @@ -0,0 +1,110 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + notify_service "code.gitea.io/gitea/services/notify" +) + +func updateMilestoneCounters(ctx context.Context, issue *issues_model.Issue, id int64) error { + if issue.NoAutoTime { + // We set the milestone's update date to the max of the + // milestone and issue update dates. + // Note: we can not call UpdateMilestoneCounters() if the + // milestone's update date is to be kept, because that function + // auto-updates the dates. + milestone, err := issues_model.GetMilestoneByRepoID(ctx, issue.RepoID, id) + if err != nil { + return fmt.Errorf("GetMilestoneByRepoID: %w", err) + } + updatedUnix := milestone.UpdatedUnix + if issue.UpdatedUnix > updatedUnix { + updatedUnix = issue.UpdatedUnix + } + if err := issues_model.UpdateMilestoneCountersWithDate(ctx, id, updatedUnix); err != nil { + return err + } + } else { + if err := issues_model.UpdateMilestoneCounters(ctx, id); err != nil { + return err + } + } + return nil +} + +func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) error { + // Only check if milestone exists if we don't remove it. + if issue.MilestoneID > 0 { + has, err := issues_model.HasMilestoneByRepoID(ctx, issue.RepoID, issue.MilestoneID) + if err != nil { + return fmt.Errorf("HasMilestoneByRepoID: %w", err) + } + if !has { + return fmt.Errorf("HasMilestoneByRepoID: issue doesn't exist") + } + } + + if err := issues_model.UpdateIssueCols(ctx, issue, "milestone_id"); err != nil { + return err + } + + if oldMilestoneID > 0 { + if err := updateMilestoneCounters(ctx, issue, oldMilestoneID); err != nil { + return err + } + } + + if issue.MilestoneID > 0 { + if err := updateMilestoneCounters(ctx, issue, issue.MilestoneID); err != nil { + return err + } + } + + if oldMilestoneID > 0 || issue.MilestoneID > 0 { + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + opts := &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeMilestone, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldMilestoneID: oldMilestoneID, + MilestoneID: issue.MilestoneID, + } + if _, err := issues_model.CreateComment(ctx, opts); err != nil { + return err + } + } + + return nil +} + +// ChangeMilestoneAssign changes assignment of milestone for issue. +func ChangeMilestoneAssign(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, oldMilestoneID int64) (err error) { + dbCtx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = changeMilestoneAssign(dbCtx, doer, issue, oldMilestoneID); err != nil { + return err + } + + if err = committer.Commit(); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + notify_service.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID) + + return nil +} diff --git a/services/issue/milestone_test.go b/services/issue/milestone_test.go new file mode 100644 index 0000000..1c06572 --- /dev/null +++ b/services/issue/milestone_test.go @@ -0,0 +1,35 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +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" + "github.com/stretchr/testify/require" +) + +func TestChangeMilestoneAssign(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 1}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.NotNil(t, issue) + assert.NotNil(t, doer) + + oldMilestoneID := issue.MilestoneID + issue.MilestoneID = 2 + require.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID)) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issue.ID, + Type: issues_model.CommentTypeMilestone, + MilestoneID: issue.MilestoneID, + OldMilestoneID: oldMilestoneID, + }) + unittest.CheckConsistencyFor(t, &issues_model.Milestone{}, &issues_model.Issue{}) +} diff --git a/services/issue/pull.go b/services/issue/pull.go new file mode 100644 index 0000000..c5a12ce --- /dev/null +++ b/services/issue/pull.go @@ -0,0 +1,147 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + org_model "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +func getMergeBase(repo *git.Repository, pr *issues_model.PullRequest, baseBranch, headBranch string) (string, error) { + // Add a temporary remote + tmpRemote := fmt.Sprintf("mergebase-%d-%d", pr.ID, time.Now().UnixNano()) + if err := repo.AddRemote(tmpRemote, repo.Path, false); err != nil { + return "", fmt.Errorf("AddRemote: %w", err) + } + defer func() { + if err := repo.RemoveRemote(tmpRemote); err != nil { + log.Error("getMergeBase: RemoveRemote: %v", err) + } + }() + + mergeBase, _, err := repo.GetMergeBase(tmpRemote, baseBranch, headBranch) + return mergeBase, err +} + +type ReviewRequestNotifier struct { + Comment *issues_model.Comment + IsAdd bool + Reviewer *user_model.User + ReviewTeam *org_model.Team +} + +func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue, pr *issues_model.PullRequest) ([]*ReviewRequestNotifier, error) { + files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"} + + if pr.IsWorkInProgress(ctx) { + return nil, nil + } + + if err := pr.LoadHeadRepo(ctx); err != nil { + return nil, err + } + + if pr.HeadRepo.IsFork { + return nil, nil + } + + if err := pr.LoadBaseRepo(ctx); err != nil { + return nil, err + } + + repo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + return nil, err + } + defer repo.Close() + + commit, err := repo.GetBranchCommit(pr.BaseRepo.DefaultBranch) + if err != nil { + return nil, err + } + + var data string + for _, file := range files { + if blob, err := commit.GetBlobByPath(file); err == nil { + data, err = blob.GetBlobContent(setting.UI.MaxDisplayFileSize) + if err == nil { + break + } + } + } + + rules, _ := issues_model.GetCodeOwnersFromContent(ctx, data) + + // get the mergebase + mergeBase, err := getMergeBase(repo, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName()) + if err != nil { + return nil, err + } + + // https://github.com/go-gitea/gitea/issues/29763, we need to get the files changed + // between the merge base and the head commit but not the base branch and the head commit + changedFiles, err := repo.GetFilesChangedBetween(mergeBase, pr.GetGitRefName()) + if err != nil { + return nil, err + } + + uniqUsers := make(map[int64]*user_model.User) + uniqTeams := make(map[string]*org_model.Team) + for _, rule := range rules { + for _, f := range changedFiles { + if (rule.Rule.MatchString(f) && !rule.Negative) || (!rule.Rule.MatchString(f) && rule.Negative) { + for _, u := range rule.Users { + uniqUsers[u.ID] = u + } + for _, t := range rule.Teams { + uniqTeams[fmt.Sprintf("%d/%d", t.OrgID, t.ID)] = t + } + } + } + } + + notifiers := make([]*ReviewRequestNotifier, 0, len(uniqUsers)+len(uniqTeams)) + + if err := issue.LoadPoster(ctx); err != nil { + return nil, err + } + + for _, u := range uniqUsers { + if u.ID != issue.Poster.ID { + comment, err := issues_model.AddReviewRequest(ctx, issue, u, issue.Poster) + if err != nil { + log.Warn("Failed add assignee user: %s to PR review: %s#%d, error: %s", u.Name, pr.BaseRepo.Name, pr.ID, err) + return nil, err + } + notifiers = append(notifiers, &ReviewRequestNotifier{ + Comment: comment, + IsAdd: true, + Reviewer: u, + }) + } + } + for _, t := range uniqTeams { + comment, err := issues_model.AddTeamReviewRequest(ctx, issue, t, issue.Poster) + if err != nil { + log.Warn("Failed add assignee team: %s to PR review: %s#%d, error: %s", t.Name, pr.BaseRepo.Name, pr.ID, err) + return nil, err + } + notifiers = append(notifiers, &ReviewRequestNotifier{ + Comment: comment, + IsAdd: true, + ReviewTeam: t, + }) + } + + return notifiers, nil +} diff --git a/services/issue/reaction.go b/services/issue/reaction.go new file mode 100644 index 0000000..dbb4735 --- /dev/null +++ b/services/issue/reaction.go @@ -0,0 +1,47 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" +) + +// CreateIssueReaction creates a reaction on issue. +func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + }) +} + +// CreateCommentReaction creates a reaction on comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster, the comment's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + CommentID: comment.ID, + }) +} diff --git a/services/issue/status.go b/services/issue/status.go new file mode 100644 index 0000000..9b6c683 --- /dev/null +++ b/services/issue/status.go @@ -0,0 +1,36 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +// ChangeStatus changes issue status to open or closed. +func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string, closed bool) error { + comment, err := issues_model.ChangeIssueStatus(ctx, issue, doer, closed) + if err != nil { + if issues_model.IsErrDependenciesLeft(err) && closed { + if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil { + log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err) + } + } + return err + } + + if closed { + if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil { + return err + } + } + + notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, closed) + + return nil +} diff --git a/services/issue/template.go b/services/issue/template.go new file mode 100644 index 0000000..47633e5 --- /dev/null +++ b/services/issue/template.go @@ -0,0 +1,193 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "fmt" + "io" + "net/url" + "path" + "strings" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/issue/template" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + + "gopkg.in/yaml.v3" +) + +// templateDirCandidates issue templates directory +var templateDirCandidates = []string{ + "ISSUE_TEMPLATE", + "issue_template", + ".forgejo/ISSUE_TEMPLATE", + ".forgejo/issue_template", + ".gitea/ISSUE_TEMPLATE", + ".gitea/issue_template", + ".github/ISSUE_TEMPLATE", + ".github/issue_template", + ".gitlab/ISSUE_TEMPLATE", + ".gitlab/issue_template", +} + +var templateConfigCandidates = []string{ + ".forgejo/ISSUE_TEMPLATE/config", + ".forgejo/issue_template/config", + ".gitea/ISSUE_TEMPLATE/config", + ".gitea/issue_template/config", + ".github/ISSUE_TEMPLATE/config", + ".github/issue_template/config", +} + +func GetDefaultTemplateConfig() api.IssueConfig { + return api.IssueConfig{ + BlankIssuesEnabled: true, + ContactLinks: make([]api.IssueConfigContactLink, 0), + } +} + +// GetTemplateConfig loads the given issue config file. +// It never returns a nil config. +func GetTemplateConfig(gitRepo *git.Repository, path string, commit *git.Commit) (api.IssueConfig, error) { + if gitRepo == nil { + return GetDefaultTemplateConfig(), nil + } + + var err error + + treeEntry, err := commit.GetTreeEntryByPath(path) + if err != nil { + return GetDefaultTemplateConfig(), err + } + + reader, err := treeEntry.Blob().DataAsync() + if err != nil { + log.Debug("DataAsync: %v", err) + return GetDefaultTemplateConfig(), nil + } + + defer reader.Close() + + configContent, err := io.ReadAll(reader) + if err != nil { + return GetDefaultTemplateConfig(), err + } + + issueConfig := GetDefaultTemplateConfig() + if err := yaml.Unmarshal(configContent, &issueConfig); err != nil { + return GetDefaultTemplateConfig(), err + } + + for pos, link := range issueConfig.ContactLinks { + if link.Name == "" { + return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing name key", pos+1) + } + + if link.URL == "" { + return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing url key", pos+1) + } + + if link.About == "" { + return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing about key", pos+1) + } + + _, err = url.ParseRequestURI(link.URL) + if err != nil { + return GetDefaultTemplateConfig(), fmt.Errorf("%s is not a valid URL", link.URL) + } + } + + return issueConfig, nil +} + +// IsTemplateConfig returns if the given path is a issue config file. +func IsTemplateConfig(path string) bool { + for _, configName := range templateConfigCandidates { + if path == configName+".yaml" || path == configName+".yml" { + return true + } + } + return false +} + +// GetTemplatesFromDefaultBranch checks for issue templates in the repo's default branch, +// returns valid templates and the errors of invalid template files. +func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) ([]*api.IssueTemplate, map[string]error) { + var issueTemplates []*api.IssueTemplate + + if repo.IsEmpty { + return issueTemplates, nil + } + + commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + if err != nil { + return issueTemplates, nil + } + + invalidFiles := map[string]error{} + for _, dirName := range templateDirCandidates { + tree, err := commit.SubTree(dirName) + if err != nil { + log.Debug("get sub tree of %s: %v", dirName, err) + continue + } + entries, err := tree.ListEntries() + if err != nil { + log.Debug("list entries in %s: %v", dirName, err) + return issueTemplates, nil + } + for _, entry := range entries { + if !template.CouldBe(entry.Name()) { + continue + } + fullName := path.Join(dirName, entry.Name()) + if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil { + invalidFiles[fullName] = err + } else { + if !strings.HasPrefix(it.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref> + it.Ref = git.BranchPrefix + it.Ref + } + issueTemplates = append(issueTemplates, it) + } + } + } + return issueTemplates, invalidFiles +} + +// GetTemplateConfigFromDefaultBranch returns the issue config for this repo. +// It never returns a nil config. +func GetTemplateConfigFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) (api.IssueConfig, error) { + if repo.IsEmpty { + return GetDefaultTemplateConfig(), nil + } + + commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + if err != nil { + return GetDefaultTemplateConfig(), err + } + + for _, configName := range templateConfigCandidates { + if _, err := commit.GetTreeEntryByPath(configName + ".yaml"); err == nil { + return GetTemplateConfig(gitRepo, configName+".yaml", commit) + } + + if _, err := commit.GetTreeEntryByPath(configName + ".yml"); err == nil { + return GetTemplateConfig(gitRepo, configName+".yml", commit) + } + } + + return GetDefaultTemplateConfig(), nil +} + +func HasTemplatesOrContactLinks(repo *repo.Repository, gitRepo *git.Repository) bool { + ret, _ := GetTemplatesFromDefaultBranch(repo, gitRepo) + if len(ret) > 0 { + return true + } + + issueConfig, _ := GetTemplateConfigFromDefaultBranch(repo, gitRepo) + return len(issueConfig.ContactLinks) > 0 +} |