first-commit
This commit is contained in:
328
services/issue/assignee.go
Normal file
328
services/issue/assignee.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// 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
|
||||
}
|
||||
issue.AssigneeID = assigneeID
|
||||
issue.Assignee = assignee
|
||||
|
||||
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 *user_model.User, permDoer *access_model.Permission, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) {
|
||||
err = isValidReviewRequest(ctx, reviewer, doer, isAdd, issue, permDoer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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.PosterID)
|
||||
|
||||
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.PosterID)
|
||||
|
||||
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) {
|
||||
err = isValidTeamReviewRequest(ctx, reviewer, doer, isAdd, issue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, reviewNotifiers []*ReviewRequestNotifier) {
|
||||
for _, reviewNotifier := range reviewNotifiers {
|
||||
if reviewNotifier.Reviewer != nil {
|
||||
notify_service.PullRequestReviewRequest(ctx, issue.Poster, issue, reviewNotifier.Reviewer, reviewNotifier.IsAdd, reviewNotifier.Comment)
|
||||
} else if reviewNotifier.ReviewTeam != nil {
|
||||
if err := teamReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifier.ReviewTeam, reviewNotifier.IsAdd, reviewNotifier.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, posterID int64) bool {
|
||||
if repo.IsArchived {
|
||||
return false
|
||||
}
|
||||
// The poster of the PR can change the reviewers
|
||||
if doer.ID == 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.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
|
||||
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
|
||||
}
|
47
services/issue/assignee_test.go
Normal file
47
services/issue/assignee_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestDeleteNotPassedAssignee(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Fake issue with assignees
|
||||
issue, err := issues_model.GetIssueByID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = issue.LoadAttributes(db.DefaultContext)
|
||||
assert.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
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if he got removed
|
||||
isAssigned, err := issues_model.IsUserAssignedToIssue(db.DefaultContext, issue, user1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, isAssigned)
|
||||
|
||||
// Clean everyone
|
||||
err = DeleteNotPassedAssignee(db.DefaultContext, issue, user1, []*user_model.User{})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, issue.Assignees)
|
||||
|
||||
// Reload to check they're gone
|
||||
issue.ResetAttributesLoaded()
|
||||
assert.NoError(t, issue.LoadAssignees(db.DefaultContext))
|
||||
assert.Empty(t, issue.Assignees)
|
||||
assert.Empty(t, issue.Assignee)
|
||||
}
|
182
services/issue/comments.go
Normal file
182
services/issue/comments.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
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/gitrepo"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
git_service "code.gitea.io/gitea/services/git"
|
||||
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 errors.New("cannot create reference with empty commit SHA")
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) {
|
||||
if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) {
|
||||
if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin {
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
}
|
||||
|
||||
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.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Issue.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, c.Issue.PosterID, c.Issue.Repo.OwnerID) {
|
||||
if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, c.Issue.Repo, doer); !isAdmin {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
}
|
||||
|
||||
needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport()
|
||||
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 {
|
||||
err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
notify_service.DeleteComment(ctx, doer, comment)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCommentPushCommits Load push commits
|
||||
func LoadCommentPushCommits(ctx context.Context, c *issues_model.Comment) (err error) {
|
||||
if c.Content == "" || c.Commits != nil || c.Type != issues_model.CommentTypePullRequestPush {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data issues_model.PushActionContent
|
||||
err = json.Unmarshal([]byte(c.Content), &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.IsForcePush = data.IsForcePush
|
||||
|
||||
if c.IsForcePush {
|
||||
if len(data.CommitIDs) != 2 {
|
||||
return nil
|
||||
}
|
||||
c.OldCommit = data.CommitIDs[0]
|
||||
c.NewCommit = data.CommitIDs[1]
|
||||
} else {
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, c.Issue.Repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.CommitsNum = int64(len(c.Commits))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
210
services/issue/commit.go
Normal file
210
services/issue/commit.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
refIssue.Repo = refRepo
|
||||
if ref.Action == references.XRefActionCloses && !refIssue.IsClosed {
|
||||
if len(ref.TimeLog) > 0 {
|
||||
if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := CloseIssue(ctx, refIssue, doer, c.Sha1); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if ref.Action == references.XRefActionReopens && refIssue.IsClosed {
|
||||
if err := ReopenIssue(ctx, refIssue, doer, c.Sha1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
301
services/issue/commit_test.go
Normal file
301
services/issue/commit_test.go
Normal file
@@ -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/assert"
|
||||
)
|
||||
|
||||
func TestUpdateIssuesCommit(t *testing.T) {
|
||||
assert.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")
|
||||
assert.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")
|
||||
assert.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")
|
||||
assert.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) {
|
||||
assert.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")
|
||||
assert.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) {
|
||||
assert.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")
|
||||
assert.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) {
|
||||
assert.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")
|
||||
assert.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) {
|
||||
assert.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")
|
||||
assert.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) {
|
||||
assert.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")
|
||||
assert.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{})
|
||||
}
|
36
services/issue/content.go
Normal file
36
services/issue/content.go
Normal file
@@ -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"
|
||||
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"
|
||||
)
|
||||
|
||||
// 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) error {
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) {
|
||||
if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
389
services/issue/issue.go
Normal file
389
services/issue/issue.go
Normal file
@@ -0,0 +1,389 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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/container"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
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, projectID int64) error {
|
||||
if err := issue.LoadPoster(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
if projectID > 0 {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); 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.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) {
|
||||
if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var reviewNotifiers []*ReviewRequestNotifier
|
||||
if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
reviewNotifiers, err = PullRequestCodeOwnersReview(ctx, issue.PullRequest)
|
||||
if err != nil {
|
||||
log.Error("PullRequestCodeOwnersReview: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
notify_service.IssueChangeTitle(ctx, doer, issue, oldTitle)
|
||||
ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifiers)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeTimeEstimate changes the time estimate of this issue, as the given user.
|
||||
func ChangeTimeEstimate(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) {
|
||||
issue.TimeEstimate = timeEstimate
|
||||
|
||||
return issues_model.ChangeIssueTimeEstimate(ctx, issue, doer, timeEstimate)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
uniqueAssignees := container.SetOf(multipleAssignees...)
|
||||
|
||||
// Keep the old assignee thingy for compatibility reasons
|
||||
if oneAssignee != "" {
|
||||
uniqueAssignees.Add(oneAssignee)
|
||||
}
|
||||
|
||||
// Loop through all assignees to add them
|
||||
allNewAssignees := make([]*user_model.User, 0, len(uniqueAssignees))
|
||||
for _, assigneeName := range uniqueAssignees.Values() {
|
||||
assignee, err := user_model.GetUserByName(ctx, assigneeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, assignee.ID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
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
|
||||
attachmentPaths, err := deleteIssue(ctx, issue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attachmentPath := range attachmentPaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", attachmentPath)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
ref := git.RefName(issue.Ref)
|
||||
issueRefEndNames[issue.ID] = ref.ShortName()
|
||||
issueRefURLs[issue.ID] = repoLink + "/src/" + ref.RefWebLinkPath()
|
||||
}
|
||||
}
|
||||
return issueRefEndNames, issueRefURLs
|
||||
}
|
||||
|
||||
// deleteIssue deletes the issue
|
||||
func deleteIssue(ctx context.Context, issue *issues_model.Issue) ([]string, error) {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if _, err := db.GetEngine(ctx).ID(issue.ID).NoAutoCondition().Delete(issue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update the total issue numbers
|
||||
if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil {
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil {
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
|
||||
// find attachments related to this issue and remove them
|
||||
if err := issue.LoadAttachments(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var attachmentPaths []string
|
||||
for i := range issue.Attachments {
|
||||
attachmentPaths = append(attachmentPaths, 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},
|
||||
&issues_model.IssuePin{IssueID: issue.ID},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := committer.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attachmentPaths, nil
|
||||
}
|
||||
|
||||
// DeleteOrphanedIssues delete issues without a repo
|
||||
func DeleteOrphanedIssues(ctx context.Context) error {
|
||||
var attachmentPaths []string
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repoIDs, err := issues_model.GetOrphanedIssueRepoIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range repoIDs {
|
||||
paths, err := DeleteIssuesByRepoID(ctx, repoIDs[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentPaths = append(attachmentPaths, paths...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove issue attachment files.
|
||||
for i := range attachmentPaths {
|
||||
system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", attachmentPaths[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIssuesByRepoID deletes issues by repositories id
|
||||
func DeleteIssuesByRepoID(ctx context.Context, repoID int64) (attachmentPaths []string, err error) {
|
||||
for {
|
||||
issues := make([]*issues_model.Issue, 0, db.DefaultMaxInSize)
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("repo_id = ?", repoID).
|
||||
OrderBy("id").
|
||||
Limit(db.DefaultMaxInSize).
|
||||
Find(&issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
issueAttachPaths, err := deleteIssue(ctx, issue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deleteIssue [issue_id: %d]: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
attachmentPaths = append(attachmentPaths, issueAttachPaths...)
|
||||
}
|
||||
}
|
||||
|
||||
return attachmentPaths, err
|
||||
}
|
86
services/issue/issue_test.go
Normal file
86
services/issue/issue_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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.Equal(t, map[int64]string{1: "branch1", 2: "tag1", 3: "c0ffee"}, endNames)
|
||||
assert.Equal(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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
issueIDs, err := issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, issueIDs, 5)
|
||||
|
||||
issue := &issues_model.Issue{
|
||||
RepoID: 1,
|
||||
ID: issueIDs[2],
|
||||
}
|
||||
|
||||
_, err = deleteIssue(db.DefaultContext, issue)
|
||||
assert.NoError(t, err)
|
||||
issueIDs, err = issues_model.GetIssueIDsByRepoID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, issueIDs, 4)
|
||||
|
||||
// check attachment removal
|
||||
attachments, err := repo_model.GetAttachmentsByIssueID(db.DefaultContext, 4)
|
||||
assert.NoError(t, err)
|
||||
issue, err = issues_model.GetIssueByID(db.DefaultContext, 4)
|
||||
assert.NoError(t, err)
|
||||
_, err = deleteIssue(db.DefaultContext, issue)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachments, 2)
|
||||
for i := range attachments {
|
||||
attachment, err := repo_model.GetAttachmentByUUID(db.DefaultContext, attachments[i].UUID)
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err)
|
||||
issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2)
|
||||
assert.NoError(t, err)
|
||||
err = issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2)
|
||||
assert.NoError(t, err)
|
||||
left, err := issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, left)
|
||||
|
||||
_, err = deleteIssue(db.DefaultContext, issue2)
|
||||
assert.NoError(t, err)
|
||||
left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, left)
|
||||
}
|
95
services/issue/label.go
Normal file
95
services/issue/label.go
Normal file
@@ -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
|
||||
}
|
62
services/issue/label_test.go
Normal file
62
services/issue/label_test.go
Normal file
@@ -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/assert"
|
||||
)
|
||||
|
||||
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 {
|
||||
assert.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})
|
||||
assert.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 {
|
||||
assert.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})
|
||||
assert.NoError(t, AddLabel(db.DefaultContext, issue, doer, label))
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: test.labelID})
|
||||
}
|
||||
}
|
17
services/issue/main_test.go
Normal file
17
services/issue/main_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
89
services/issue/milestone.go
Normal file
89
services/issue/milestone.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 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 errors.New("HasMilestoneByRepoID: issue doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.UpdateIssueCols(ctx, issue, "milestone_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldMilestoneID > 0 {
|
||||
if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if issue.MilestoneID > 0 {
|
||||
if err := issues_model.UpdateMilestoneCounters(ctx, 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
|
||||
}
|
||||
}
|
||||
|
||||
if issue.MilestoneID == 0 {
|
||||
issue.Milestone = nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
42
services/issue/milestone_test.go
Normal file
42
services/issue/milestone_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestChangeMilestoneAssign(t *testing.T) {
|
||||
assert.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
|
||||
assert.NoError(t, issue.LoadMilestone(db.DefaultContext))
|
||||
assert.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{})
|
||||
assert.NotNil(t, issue.Milestone)
|
||||
|
||||
oldMilestoneID = issue.MilestoneID
|
||||
issue.MilestoneID = 0
|
||||
assert.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID))
|
||||
assert.EqualValues(t, 0, issue.MilestoneID)
|
||||
assert.Nil(t, issue.Milestone)
|
||||
}
|
183
services/issue/pull.go
Normal file
183
services/issue/pull.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"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
|
||||
}
|
||||
|
||||
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
|
||||
|
||||
func IsCodeOwnerFile(f string) bool {
|
||||
return slices.Contains(codeOwnerFiles, f)
|
||||
}
|
||||
|
||||
func PullRequestCodeOwnersReview(ctx context.Context, pr *issues_model.PullRequest) ([]*ReviewRequestNotifier, error) {
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issue := pr.Issue
|
||||
if pr.IsWorkInProgress(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr.Issue.Repo = pr.BaseRepo
|
||||
|
||||
if pr.BaseRepo.IsFork {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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 codeOwnerFiles {
|
||||
if blob, err := commit.GetBlobByPath(file); err == nil {
|
||||
data, err = blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(ctx, data)
|
||||
if len(rules) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// load all reviews from database
|
||||
latestReivews, _, err := issues_model.GetReviewsByIssueID(ctx, pr.IssueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contain := func(list issues_model.ReviewList, u *user_model.User) bool {
|
||||
for _, review := range list {
|
||||
if review.ReviewerTeamID == 0 && review.ReviewerID == u.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, u := range uniqUsers {
|
||||
if u.ID != issue.Poster.ID && !contain(latestReivews, u) {
|
||||
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
|
||||
}
|
||||
if comment == nil { // comment maybe nil if review type is ReviewTypeRequest
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
if comment == nil { // comment maybe nil if review type is ReviewTypeRequest
|
||||
continue
|
||||
}
|
||||
notifiers = append(notifiers, &ReviewRequestNotifier{
|
||||
Comment: comment,
|
||||
IsAdd: true,
|
||||
ReviewTeam: t,
|
||||
})
|
||||
}
|
||||
|
||||
return notifiers, nil
|
||||
}
|
50
services/issue/reaction.go
Normal file
50
services/issue/reaction.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2024 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"
|
||||
)
|
||||
|
||||
// CreateIssueReaction creates a reaction on an 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
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) {
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doer.ID,
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateCommentReaction creates a reaction on a comment.
|
||||
func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) {
|
||||
if err := comment.LoadIssue(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comment.Issue.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, comment.Issue.PosterID, comment.Issue.Repo.OwnerID, comment.PosterID) {
|
||||
return nil, user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
|
||||
Type: content,
|
||||
DoerID: doer.ID,
|
||||
IssueID: comment.Issue.ID,
|
||||
CommentID: comment.ID,
|
||||
})
|
||||
}
|
162
services/issue/reaction_test.go
Normal file
162
services/issue/reaction_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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"
|
||||
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/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func addReaction(t *testing.T, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) {
|
||||
var reaction *issues_model.Reaction
|
||||
var err error
|
||||
if comment == nil {
|
||||
reaction, err = CreateIssueReaction(db.DefaultContext, doer, issue, content)
|
||||
} else {
|
||||
reaction, err = CreateCommentReaction(db.DefaultContext, doer, comment, content)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reaction)
|
||||
}
|
||||
|
||||
func TestIssueAddReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
addReaction(t, user1, issue, nil, "heart")
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID})
|
||||
}
|
||||
|
||||
func TestIssueAddDuplicateReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
addReaction(t, user1, issue, nil, "heart")
|
||||
|
||||
reaction, err := CreateIssueReaction(db.DefaultContext, user1, issue, "heart")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, issues_model.ErrReactionAlreadyExist{Reaction: "heart"}, err)
|
||||
|
||||
existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, existingR.ID, reaction.ID)
|
||||
}
|
||||
|
||||
func TestIssueDeleteReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
addReaction(t, user1, issue, nil, "heart")
|
||||
|
||||
err := issues_model.DeleteIssueReaction(db.DefaultContext, user1.ID, issue.ID, "heart")
|
||||
assert.NoError(t, err)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID})
|
||||
}
|
||||
|
||||
func TestIssueReactionCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
setting.UI.ReactionMaxUserNum = 2
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
ghost := user_model.NewGhostUser()
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
addReaction(t, user1, issue, nil, "heart")
|
||||
addReaction(t, user2, issue, nil, "heart")
|
||||
addReaction(t, org3, issue, nil, "heart")
|
||||
addReaction(t, org3, issue, nil, "+1")
|
||||
addReaction(t, user4, issue, nil, "+1")
|
||||
addReaction(t, user4, issue, nil, "heart")
|
||||
addReaction(t, ghost, issue, nil, "-1")
|
||||
|
||||
reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reactionsList, 7)
|
||||
_, err = reactionsList.LoadUsers(db.DefaultContext, repo)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reactions := reactionsList.GroupByType()
|
||||
assert.Len(t, reactions["heart"], 4)
|
||||
assert.Equal(t, 2, reactions["heart"].GetMoreUserCount())
|
||||
assert.Equal(t, user1.Name+", "+user2.Name, reactions["heart"].GetFirstUsers())
|
||||
assert.True(t, reactions["heart"].HasUser(1))
|
||||
assert.False(t, reactions["heart"].HasUser(5))
|
||||
assert.False(t, reactions["heart"].HasUser(0))
|
||||
assert.Len(t, reactions["+1"], 2)
|
||||
assert.Equal(t, 0, reactions["+1"].GetMoreUserCount())
|
||||
assert.Len(t, reactions["-1"], 1)
|
||||
}
|
||||
|
||||
func TestIssueCommentAddReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
|
||||
addReaction(t, user1, nil, comment, "heart")
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID})
|
||||
}
|
||||
|
||||
func TestIssueCommentDeleteReaction(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
|
||||
addReaction(t, user1, nil, comment, "heart")
|
||||
addReaction(t, user2, nil, comment, "heart")
|
||||
addReaction(t, org3, nil, comment, "heart")
|
||||
addReaction(t, user4, nil, comment, "+1")
|
||||
|
||||
reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{
|
||||
IssueID: comment.IssueID,
|
||||
CommentID: comment.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reactionsList, 4)
|
||||
|
||||
reactions := reactionsList.GroupByType()
|
||||
assert.Len(t, reactions["heart"], 3)
|
||||
assert.Len(t, reactions["+1"], 1)
|
||||
}
|
||||
|
||||
func TestIssueCommentReactionCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
|
||||
addReaction(t, user1, nil, comment, "heart")
|
||||
assert.NoError(t, issues_model.DeleteCommentReaction(db.DefaultContext, user1.ID, comment.IssueID, comment.ID, "heart"))
|
||||
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID})
|
||||
}
|
59
services/issue/status.go
Normal file
59
services/issue/status.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
// CloseIssue close an issue.
|
||||
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
||||
dbCtx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
comment, err := issues_model.CloseIssue(dbCtx, issue, doer)
|
||||
if err != nil {
|
||||
if issues_model.IsErrDependenciesLeft(err) {
|
||||
if _, err := issues_model.FinishIssueStopwatch(dbCtx, doer, issue); err != nil {
|
||||
log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := issues_model.FinishIssueStopwatch(dbCtx, doer, issue); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committer.Close()
|
||||
|
||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReopenIssue reopen an issue.
|
||||
// FIXME: If some issues dependent this one are closed, should we also reopen them?
|
||||
func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
||||
comment, err := issues_model.ReopenIssue(ctx, issue, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
|
||||
|
||||
return nil
|
||||
}
|
73
services/issue/suggestion.go
Normal file
73
services/issue/suggestion.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
func GetSuggestion(ctx context.Context, repo *repo_model.Repository, isPull optional.Option[bool], keyword string) ([]*structs.Issue, error) {
|
||||
var issues issues_model.IssueList
|
||||
var err error
|
||||
pageSize := 5
|
||||
if keyword == "" {
|
||||
issues, err = issues_model.FindLatestUpdatedIssues(ctx, repo.ID, isPull, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
indexKeyword, _ := strconv.ParseInt(keyword, 10, 64)
|
||||
var issueByIndex *issues_model.Issue
|
||||
var excludedID int64
|
||||
if indexKeyword > 0 {
|
||||
issueByIndex, err = issues_model.GetIssueByIndex(ctx, repo.ID, indexKeyword)
|
||||
if err != nil && !issues_model.IsErrIssueNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if issueByIndex != nil {
|
||||
excludedID = issueByIndex.ID
|
||||
pageSize--
|
||||
}
|
||||
}
|
||||
|
||||
issues, err = issues_model.FindIssuesSuggestionByKeyword(ctx, repo.ID, keyword, isPull, excludedID, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if issueByIndex != nil {
|
||||
issues = append([]*issues_model.Issue{issueByIndex}, issues...)
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues.LoadPullRequests(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
suggestions := make([]*structs.Issue, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
suggestion := &structs.Issue{
|
||||
ID: issue.ID,
|
||||
Index: issue.Index,
|
||||
Title: issue.Title,
|
||||
State: issue.State(),
|
||||
}
|
||||
|
||||
if issue.IsPull && issue.PullRequest != nil {
|
||||
suggestion.PullRequest = &structs.PullRequestMeta{
|
||||
HasMerged: issue.PullRequest.HasMerged,
|
||||
IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx),
|
||||
}
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
57
services/issue/suggestion_test.go
Normal file
57
services/issue/suggestion_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Suggestion(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
testCases := []struct {
|
||||
keyword string
|
||||
isPull optional.Option[bool]
|
||||
expectedIndexes []int64
|
||||
}{
|
||||
{
|
||||
keyword: "",
|
||||
expectedIndexes: []int64{5, 1, 4, 2, 3},
|
||||
},
|
||||
{
|
||||
keyword: "1",
|
||||
expectedIndexes: []int64{1},
|
||||
},
|
||||
{
|
||||
keyword: "issue",
|
||||
expectedIndexes: []int64{4, 1, 2, 3},
|
||||
},
|
||||
{
|
||||
keyword: "pull",
|
||||
expectedIndexes: []int64{5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.keyword, func(t *testing.T) {
|
||||
issues, err := GetSuggestion(db.DefaultContext, repo1, testCase.isPull, testCase.keyword)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issueIndexes := make([]int64, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
issueIndexes = append(issueIndexes, issue.Index)
|
||||
}
|
||||
assert.Equal(t, testCase.expectedIndexes, issueIndexes)
|
||||
})
|
||||
}
|
||||
}
|
189
services/issue/template.go
Normal file
189
services/issue/template.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// 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",
|
||||
".gitea/ISSUE_TEMPLATE",
|
||||
".gitea/issue_template",
|
||||
".github/ISSUE_TEMPLATE",
|
||||
".github/issue_template",
|
||||
".gitlab/ISSUE_TEMPLATE",
|
||||
".gitlab/issue_template",
|
||||
}
|
||||
|
||||
var templateConfigCandidates = []string{
|
||||
".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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ParseTemplatesFromDefaultBranch parses the issue templates in the repo's default branch,
|
||||
// returns valid templates and the errors of invalid template files (the errors map is guaranteed to be non-nil).
|
||||
func ParseTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) (ret struct {
|
||||
IssueTemplates []*api.IssueTemplate
|
||||
TemplateErrors map[string]error
|
||||
},
|
||||
) {
|
||||
ret.TemplateErrors = map[string]error{}
|
||||
if repo.IsEmpty {
|
||||
return ret
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return ret
|
||||
}
|
||||
|
||||
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 ret
|
||||
}
|
||||
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 {
|
||||
ret.TemplateErrors[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
|
||||
}
|
||||
ret.IssueTemplates = append(ret.IssueTemplates, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// 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 := ParseTemplatesFromDefaultBranch(repo, gitRepo)
|
||||
if len(ret.IssueTemplates) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
issueConfig, _ := GetTemplateConfigFromDefaultBranch(repo, gitRepo)
|
||||
return len(issueConfig.ContactLinks) > 0
|
||||
}
|
Reference in New Issue
Block a user