summaryrefslogtreecommitdiffstats
path: root/services/issue
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/issue/assignee.go314
-rw-r--r--services/issue/assignee_test.go48
-rw-r--r--services/issue/comments.go136
-rw-r--r--services/issue/comments_test.go147
-rw-r--r--services/issue/commit.go202
-rw-r--r--services/issue/commit_test.go301
-rw-r--r--services/issue/content.go25
-rw-r--r--services/issue/issue.go349
-rw-r--r--services/issue/issue_test.go87
-rw-r--r--services/issue/label.go95
-rw-r--r--services/issue/label_test.go62
-rw-r--r--services/issue/main_test.go23
-rw-r--r--services/issue/milestone.go110
-rw-r--r--services/issue/milestone_test.go35
-rw-r--r--services/issue/pull.go147
-rw-r--r--services/issue/reaction.go47
-rw-r--r--services/issue/status.go36
-rw-r--r--services/issue/template.go193
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
+}