This commit is contained in:
@@ -7,9 +7,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -64,8 +64,8 @@ func GetAssigneeIDsByIssue(ctx context.Context, issueID int64) ([]int64, error)
|
||||
}
|
||||
|
||||
// IsUserAssignedToIssue returns true when the user is assigned to the issue
|
||||
func IsUserAssignedToIssue(ctx context.Context, issue *Issue, user *user_model.User) (isAssigned bool, err error) {
|
||||
return db.Exist[IssueAssignees](ctx, builder.Eq{"assignee_id": user.ID, "issue_id": issue.ID})
|
||||
func IsUserAssignedToIssue(ctx context.Context, issue *Issue, userID int64) (isAssigned bool, err error) {
|
||||
return db.Exist[IssueAssignees](ctx, builder.Eq{"assignee_id": userID, "issue_id": issue.ID})
|
||||
}
|
||||
|
||||
type AssignedIssuesOptions struct {
|
||||
@@ -170,7 +170,7 @@ func toggleUserAssignee(ctx context.Context, issue *Issue, assigneeID int64) (re
|
||||
}
|
||||
|
||||
// MakeIDsFromAPIAssigneesToAdd returns an array with all assignee IDs
|
||||
func MakeIDsFromAPIAssigneesToAdd(ctx context.Context, oneAssignee string, multipleAssignees []string) (assigneeIDs []int64, err error) {
|
||||
func MakeIDsFromAPIAssigneesToAdd(ctx context.Context, oneAssignee string, multipleAssignees []string) ([]int64, error) {
|
||||
var requestAssignees []string
|
||||
|
||||
// Keeping the old assigning method for compatibility reasons
|
||||
@@ -184,7 +184,5 @@ func MakeIDsFromAPIAssigneesToAdd(ctx context.Context, oneAssignee string, multi
|
||||
}
|
||||
|
||||
// Get the IDs of all assignees
|
||||
assigneeIDs, err = user_model.GetUserIDsByNames(ctx, requestAssignees, false)
|
||||
|
||||
return assigneeIDs, err
|
||||
return user_model.GetUserIDsByNames(ctx, requestAssignees, false)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -40,7 +40,7 @@ func TestUpdateAssignee(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if he got removed
|
||||
isAssigned, err := issues_model.IsUserAssignedToIssue(t.Context(), issue, user1)
|
||||
isAssigned, err := issues_model.IsUserAssignedToIssue(t.Context(), issue, user1.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isAssigned)
|
||||
|
||||
@@ -56,12 +56,12 @@ func TestUpdateAssignee(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check if the user is assigned
|
||||
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, user2)
|
||||
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, user2.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, isAssigned)
|
||||
|
||||
// This user should not be assigned
|
||||
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, &user_model.User{ID: 4})
|
||||
isAssigned, err = issues_model.IsUserAssignedToIssue(t.Context(), issue, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isAssigned)
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ import (
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/organization"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -534,8 +536,17 @@ func (c *Comment) EventTag() string {
|
||||
return fmt.Sprintf("event-%d", c.ID)
|
||||
}
|
||||
|
||||
func (c *Comment) GetSanitizedContentHTML() template.HTML {
|
||||
// mainly for type=4 CommentTypeCommitRef
|
||||
// the content is a link like <a href="{RepoLink}/commit/{CommitID}">message title</a> (from CreateRefComment)
|
||||
return markup.Sanitize(c.Content)
|
||||
}
|
||||
|
||||
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
|
||||
func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
if c.LabelID == 0 {
|
||||
return nil
|
||||
}
|
||||
var label Label
|
||||
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
|
||||
if err != nil {
|
||||
@@ -543,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
} else if has {
|
||||
c.Label = &label
|
||||
} else {
|
||||
// Ignore Label is deleted, but not clear this table
|
||||
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
|
||||
// label was deleted but comment rows referencing it were not cleaned up
|
||||
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -619,11 +630,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, c.IssueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = c.IssueID
|
||||
attachments[i].CommentID = c.ID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
@@ -636,36 +654,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
}
|
||||
|
||||
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
|
||||
if c.AssigneeID > 0 && c.Assignee == nil {
|
||||
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
|
||||
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return err
|
||||
}
|
||||
c.Assignee = user_model.NewGhostUser()
|
||||
}
|
||||
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
if err = c.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.LoadRepo(ctx); err != nil {
|
||||
}
|
||||
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Issue.Repo.Owner.IsOrganization() {
|
||||
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil && !organization.IsErrTeamNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -788,8 +788,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
|
||||
}
|
||||
|
||||
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
|
||||
if c.AssigneeID > 0 {
|
||||
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
|
||||
if c.Assignee != nil {
|
||||
if c.RemovedAssignee {
|
||||
if c.PosterID == c.AssigneeID {
|
||||
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
|
||||
@@ -798,14 +797,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
|
||||
}
|
||||
teamName := "Ghost Team"
|
||||
if c.AssigneeTeam != nil {
|
||||
teamName = c.AssigneeTeam.Name
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
|
||||
// impossible fallback
|
||||
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
|
||||
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
|
||||
// CreateComment creates comment with context
|
||||
@@ -1117,7 +1122,7 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList,
|
||||
}
|
||||
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, opts)
|
||||
db.SetSessionPagination(sess, opts)
|
||||
}
|
||||
|
||||
// WARNING: If you change this order you will need to fix createCodeComment
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/renderhelper"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -79,6 +79,14 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comments.loadResolveDoers(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comments.loadReactions(ctx, issue.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find all reviews by ReviewID
|
||||
reviews := make(map[int64]*Review)
|
||||
ids := make([]int64, 0, len(comments))
|
||||
@@ -107,14 +115,6 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
|
||||
comments[n] = comment
|
||||
n++
|
||||
|
||||
if err := comment.LoadResolveDoer(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comment.LoadReactions(ctx, issue.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var err error
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
|
||||
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
|
||||
|
||||
@@ -6,11 +6,12 @@ package issues
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// CommentList defines a list of comments
|
||||
@@ -80,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Label = commentLabels[comment.ID]
|
||||
comment.Label = commentLabels[comment.LabelID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -444,6 +445,73 @@ func (comments CommentList) loadReviews(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadResolveDoers bulk-loads the resolve doer for all code comments that have one.
|
||||
func (comments CommentList) loadResolveDoers(ctx context.Context) error {
|
||||
resolveDoerIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) {
|
||||
return c.ResolveDoerID, c.ResolveDoerID != 0 && c.Type == CommentTypeCode
|
||||
})
|
||||
if len(resolveDoerIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
userMaps, err := user_model.GetUsersMapByIDs(ctx, resolveDoerIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.ResolveDoerID == 0 || comment.Type != CommentTypeCode {
|
||||
continue
|
||||
}
|
||||
if u, ok := userMaps[comment.ResolveDoerID]; ok {
|
||||
comment.ResolveDoer = u
|
||||
} else {
|
||||
comment.ResolveDoer = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadReactions bulk-loads reactions for all comments in the list.
|
||||
func (comments CommentList) loadReactions(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if len(comments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commentIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) {
|
||||
return c.ID, c.Reactions == nil
|
||||
})
|
||||
if len(commentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allReactions ReactionList
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("`comment_id` > 0").
|
||||
In("comment_id", commentIDs).
|
||||
In("`type`", setting.UI.Reactions).
|
||||
Asc("issue_id", "comment_id", "created_unix", "id").
|
||||
Find(&allReactions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := allReactions.LoadUsers(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reactByComment := make(map[int64]ReactionList, len(commentIDs))
|
||||
for _, r := range allReactions {
|
||||
reactByComment[r.CommentID] = append(reactByComment[r.CommentID], r)
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.Reactions == nil {
|
||||
comment.Reactions = reactByComment[comment.ID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttributes loads attributes of the comments, except for attachments and
|
||||
// comments
|
||||
func (comments CommentList) LoadAttributes(ctx context.Context) (err error) {
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -45,12 +45,27 @@ func TestCreateComment(t *testing.T) {
|
||||
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
|
||||
}
|
||||
|
||||
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
|
||||
comment := &issues_model.Comment{
|
||||
Type: issues_model.CommentTypeAssignees,
|
||||
IssueID: issue.ID,
|
||||
AssigneeTeamID: 999999, // non-existing team ID
|
||||
}
|
||||
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
|
||||
assert.NotNil(t, comment.AssigneeTeam)
|
||||
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
|
||||
}
|
||||
|
||||
func Test_UpdateCommentAttachment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
||||
attachment := repo_model.Attachment{
|
||||
Name: "test.txt",
|
||||
RepoID: issue.RepoID, // must match the comment's repo, else the cross-repo guard rejects it
|
||||
Name: "test.txt",
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), &attachment))
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/avatars"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/avatars"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -6,10 +6,10 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrDependencyExists represents a "DependencyAlreadyExists" kind of error.
|
||||
@@ -89,12 +89,6 @@ type ErrUnknownDependencyType struct {
|
||||
Type DependencyType
|
||||
}
|
||||
|
||||
// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType
|
||||
func IsErrUnknownDependencyType(err error) bool {
|
||||
_, ok := err.(ErrUnknownDependencyType)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUnknownDependencyType) Error() string {
|
||||
return fmt.Sprintf("unknown dependency type [type: %d]", err.Type)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -48,21 +48,6 @@ func (err ErrIssueNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrNewIssueInsert is used when the INSERT statement in newIssue fails
|
||||
type ErrNewIssueInsert struct {
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
// IsErrNewIssueInsert checks if an error is a ErrNewIssueInsert.
|
||||
func IsErrNewIssueInsert(err error) bool {
|
||||
_, ok := err.(ErrNewIssueInsert)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrNewIssueInsert) Error() string {
|
||||
return err.OriginalError.Error()
|
||||
}
|
||||
|
||||
var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already changed")
|
||||
|
||||
// Issue represents an issue or pull request of repository.
|
||||
@@ -74,17 +59,18 @@ type Issue struct {
|
||||
PosterID int64 `xorm:"INDEX"`
|
||||
Poster *user_model.User `xorm:"-"`
|
||||
OriginalAuthor string
|
||||
OriginalAuthorID int64 `xorm:"index"`
|
||||
Title string `xorm:"name"`
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
RenderedContent template.HTML `xorm:"-"`
|
||||
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Labels []*Label `xorm:"-"`
|
||||
isLabelsLoaded bool `xorm:"-"`
|
||||
MilestoneID int64 `xorm:"INDEX"`
|
||||
Milestone *Milestone `xorm:"-"`
|
||||
isMilestoneLoaded bool `xorm:"-"`
|
||||
Project *project_model.Project `xorm:"-"`
|
||||
OriginalAuthorID int64 `xorm:"index"`
|
||||
Title string `xorm:"name"`
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
RenderedContent template.HTML `xorm:"-"`
|
||||
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Labels []*Label `xorm:"-"`
|
||||
isLabelsLoaded bool `xorm:"-"`
|
||||
MilestoneID int64 `xorm:"INDEX"`
|
||||
Milestone *Milestone `xorm:"-"`
|
||||
isMilestoneLoaded bool `xorm:"-"`
|
||||
Projects []*project_model.Project `xorm:"-"`
|
||||
isProjectsLoaded bool `xorm:"-"`
|
||||
Priority int
|
||||
AssigneeID int64 `xorm:"-"`
|
||||
Assignee *user_model.User `xorm:"-"`
|
||||
@@ -320,7 +306,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = issue.LoadProject(ctx); err != nil {
|
||||
if err = issue.LoadProjects(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -370,12 +356,13 @@ func (issue *Issue) ResetAttributesLoaded() {
|
||||
issue.isMilestoneLoaded = false
|
||||
issue.isAttachmentsLoaded = false
|
||||
issue.isAssigneeLoaded = false
|
||||
issue.isProjectsLoaded = false
|
||||
}
|
||||
|
||||
// GetIsRead load the `IsRead` field of the issue
|
||||
func (issue *Issue) GetIsRead(ctx context.Context, userID int64) error {
|
||||
issueUser := &IssueUser{IssueID: issue.ID, UID: userID}
|
||||
if has, err := db.GetEngine(ctx).Get(issueUser); err != nil {
|
||||
issueUser, has, err := db.Get[IssueUser](ctx, builder.Eq{"issue_id": issue.ID, "uid": userID})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
issue.IsRead = false
|
||||
@@ -512,11 +499,7 @@ func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
|
||||
if index < 1 {
|
||||
return nil, ErrIssueNotExist{}
|
||||
}
|
||||
issue := &Issue{
|
||||
RepoID: repoID,
|
||||
Index: index,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(issue)
|
||||
issue, has, err := db.Get[Issue](ctx, builder.Eq{"repo_id": repoID, "`index`": index})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -585,6 +568,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) {
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) {
|
||||
issue := new(Issue)
|
||||
has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrIssueNotExist{issueID, repoID, 0}
|
||||
}
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// GetIssuesByIDs return issues with the given IDs.
|
||||
// If keepOrder is true, the order of the returned issues will be the same as the given IDs.
|
||||
func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) {
|
||||
@@ -684,7 +678,7 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio
|
||||
// sort by repo id then created date, with the issues of the same repo at the beginning of the list
|
||||
OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID)
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &opts)
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
}
|
||||
total, err = sess.FindAndCount(&issueDeps)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ package issues
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"gitea.dev/models/db"
|
||||
)
|
||||
|
||||
// RecalculateIssueIndexForRepo create issue_index for repo if not exist and
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/db"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"gitea.dev/models/db"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -185,7 +185,7 @@ func (issues IssueList) LoadMilestones(ctx context.Context) error {
|
||||
|
||||
func (issues IssueList) LoadProjects(ctx context.Context) error {
|
||||
issueIDs := issues.getIssueIDs()
|
||||
projectMaps := make(map[int64]*project_model.Project, len(issues))
|
||||
issueProjectMaps := make(map[int64][]*project_model.Project, len(issues))
|
||||
left := len(issueIDs)
|
||||
|
||||
type projectWithIssueID struct {
|
||||
@@ -202,19 +202,21 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
|
||||
Select("project.*, project_issue.issue_id").
|
||||
Join("INNER", "project_issue", "project.id = project_issue.project_id").
|
||||
In("project_issue.issue_id", issueIDs[:limit]).
|
||||
OrderBy("project_issue.issue_id ASC, project.id ASC").
|
||||
Find(&projects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, project := range projects {
|
||||
projectMaps[project.IssueID] = project.Project
|
||||
issueProjectMaps[project.IssueID] = append(issueProjectMaps[project.IssueID], project.Project)
|
||||
}
|
||||
left -= limit
|
||||
issueIDs = issueIDs[limit:]
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
issue.Project = projectMaps[issue.ID]
|
||||
issue.Projects = issueProjectMaps[issue.ID]
|
||||
issue.isProjectsLoaded = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -589,9 +591,12 @@ func (issues IssueList) GetApprovalCounts(ctx context.Context) (map[int64][]*Rev
|
||||
|
||||
func (issues IssueList) LoadIsRead(ctx context.Context, userID int64) error {
|
||||
issueIDs := issues.getIssueIDs()
|
||||
if len(issueIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
issueUsers := make([]*IssueUser, 0, len(issueIDs))
|
||||
if err := db.GetEngine(ctx).Where("uid =?", userID).
|
||||
In("issue_id").
|
||||
In("issue_id", issueIDs).
|
||||
Find(&issueUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueList_LoadRepositories(t *testing.T) {
|
||||
@@ -30,6 +31,22 @@ func TestIssueList_LoadRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueList_LoadIsRead(t *testing.T) {
|
||||
// Regression: In("issue_id") was missing the issueIDs argument, causing
|
||||
// xorm to generate "0=1" and never mark any issue as read.
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
|
||||
// Fixture: uid=1 has is_read=true on issue 1 only.
|
||||
issueList := issues_model.IssueList{issue1, issue2}
|
||||
require.NoError(t, issueList.LoadIsRead(t.Context(), 1))
|
||||
|
||||
assert.True(t, issue1.IsRead, "issue 1 should be marked read for user 1")
|
||||
assert.False(t, issue2.IsRead, "issue 2 should not be marked read for user 1")
|
||||
}
|
||||
|
||||
func TestIssueList_LoadAttributes(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
setting.Service.EnableTimetracking = true
|
||||
@@ -65,10 +82,10 @@ func TestIssueList_LoadAttributes(t *testing.T) {
|
||||
}
|
||||
if issue.ID == int64(1) {
|
||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||
assert.NotNil(t, issue.Project)
|
||||
assert.Equal(t, int64(1), issue.Project.ID)
|
||||
assert.NotEmpty(t, issue.Projects)
|
||||
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||
} else {
|
||||
assert.Nil(t, issue.Project)
|
||||
assert.Empty(t, issue.Projects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package issues
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
// IssueLockOptions defines options for locking and/or unlocking an issue/PR
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type IssuePin struct {
|
||||
@@ -165,27 +165,6 @@ func MovePin(ctx context.Context, issue *Issue, newPosition int) error {
|
||||
})
|
||||
}
|
||||
|
||||
func GetPinnedIssueIDs(ctx context.Context, repoID int64, isPull bool) ([]int64, error) {
|
||||
var issuePins []IssuePin
|
||||
if err := db.GetEngine(ctx).
|
||||
Table("issue_pin").
|
||||
Where("repo_id = ?", repoID).
|
||||
And("is_pull = ?", isPull).
|
||||
Find(&issuePins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(issuePins, func(i, j int) bool {
|
||||
return issuePins[i].PinOrder < issuePins[j].PinOrder
|
||||
})
|
||||
|
||||
var ids []int64
|
||||
for _, pin := range issuePins {
|
||||
ids = append(ids, pin.IssueID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func GetIssuePinsByRepoID(ctx context.Context, repoID int64, isPull bool) ([]*IssuePin, error) {
|
||||
var pins []*IssuePin
|
||||
if err := db.GetEngine(ctx).Where("repo_id = ? AND is_pull = ?", repoID, isPull).Find(&pins); err != nil {
|
||||
|
||||
@@ -6,47 +6,44 @@ package issues
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
project_model "gitea.dev/models/project"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// LoadProject load the project the issue was assigned to
|
||||
func (issue *Issue) LoadProject(ctx context.Context) (err error) {
|
||||
if issue.Project == nil {
|
||||
var p project_model.Project
|
||||
has, err := db.GetEngine(ctx).Table("project").
|
||||
// LoadProjects loads all projects the issue is assigned to
|
||||
func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
|
||||
if !issue.isProjectsLoaded {
|
||||
err = db.GetEngine(ctx).Table("project").
|
||||
Join("INNER", "project_issue", "project.id=project_issue.project_id").
|
||||
Where("project_issue.issue_id = ?", issue.ID).Get(&p)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
issue.Project = &p
|
||||
Where("project_issue.issue_id = ?", issue.ID).
|
||||
OrderBy("project.id ASC").
|
||||
Find(&issue.Projects)
|
||||
if err == nil {
|
||||
issue.isProjectsLoaded = true
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (issue *Issue) projectID(ctx context.Context) int64 {
|
||||
var ip project_model.ProjectIssue
|
||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
||||
if err != nil || !has {
|
||||
return 0
|
||||
}
|
||||
return ip.ProjectID
|
||||
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
|
||||
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
|
||||
return projectIDs, err
|
||||
}
|
||||
|
||||
// ProjectColumnID return project column id if issue was assigned to one
|
||||
func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
|
||||
var ip project_model.ProjectIssue
|
||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if !has {
|
||||
return 0, nil
|
||||
// ProjectColumnMap returns a map of project ID to column ID for this issue.
|
||||
func (issue *Issue) ProjectColumnMap(ctx context.Context) (map[int64]int64, error) {
|
||||
var projIssues []project_model.ProjectIssue
|
||||
if err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Find(&projIssues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ip.ProjectColumnID, nil
|
||||
|
||||
result := make(map[int64]int64, len(projIssues))
|
||||
for _, projIssue := range projIssues {
|
||||
result[projIssue.ProjectID] = projIssue.ProjectColumnID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) {
|
||||
@@ -64,73 +61,91 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IssueAssignOrRemoveProject changes the project associated with an issue
|
||||
// If newProjectID is 0, the issue is removed from the project
|
||||
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
|
||||
// IssueAssignOrRemoveProject updates the projects associated with an issue.
|
||||
// It adds projects that are in newProjectIDs but not currently assigned,
|
||||
// and removes projects that are currently assigned but not in newProjectIDs.
|
||||
// If newProjectIDs is empty, all projects are removed from the issue.
|
||||
// When adding an issue to a project, it is placed in the project's default column.
|
||||
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
oldProjectID := issue.projectID(ctx)
|
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only check if we add a new project and not remove it.
|
||||
if newProjectID > 0 {
|
||||
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
|
||||
oldProjectIDs, err := issue.projectIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)
|
||||
issue.isProjectsLoaded = false
|
||||
issue.Projects = nil
|
||||
|
||||
if len(projectsToRemove) > 0 {
|
||||
if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, projectID := range projectsToRemove {
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: projectID,
|
||||
ProjectID: 0,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(projectsToAdd) > 0 {
|
||||
projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
|
||||
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
|
||||
}
|
||||
if newColumnID == 0 {
|
||||
newDefaultColumn, err := newProject.MustDefaultColumn(ctx)
|
||||
|
||||
for _, projectID := range projectsToAdd {
|
||||
newProject, ok := projectMap[projectID]
|
||||
if !ok {
|
||||
return util.NewNotExistErrorf("project %d not found", projectID)
|
||||
}
|
||||
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
|
||||
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
|
||||
}
|
||||
|
||||
defaultColumn, err := newProject.MustDefaultColumn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newColumnID = newDefaultColumn.ID
|
||||
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, defaultColumn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Insert(ctx, &project_model.ProjectIssue{
|
||||
IssueID: issue.ID,
|
||||
ProjectID: projectID,
|
||||
ProjectColumnID: defaultColumn.ID,
|
||||
Sorting: newSorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: 0,
|
||||
ProjectID: projectID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldProjectID > 0 || newProjectID > 0 {
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: oldProjectID,
|
||||
ProjectID: newProjectID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newProjectID == 0 {
|
||||
return nil
|
||||
}
|
||||
if newColumnID == 0 {
|
||||
panic("newColumnID must not be zero") // shouldn't happen
|
||||
}
|
||||
|
||||
res := struct {
|
||||
MaxSorting int64
|
||||
IssueCount int64
|
||||
}{}
|
||||
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
|
||||
Where("project_id=?", newProjectID).
|
||||
And("project_board_id=?", newColumnID).
|
||||
Get(&res); err != nil {
|
||||
return err
|
||||
}
|
||||
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
|
||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
||||
IssueID: issue.ID,
|
||||
ProjectID: newProjectID,
|
||||
ProjectColumnID: newColumnID,
|
||||
Sorting: newSorting,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueMultipleProjects(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("GeneralTest", func(t *testing.T) {
|
||||
// Get test data
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
project1 := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
|
||||
|
||||
// Create a second project for the same repository
|
||||
project2 := &project_model.Project{
|
||||
Title: "Test Project 2",
|
||||
RepoID: issue1.RepoID,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeBasicKanban,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), project2))
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project2.ID)
|
||||
}()
|
||||
|
||||
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, issue1.Projects)
|
||||
|
||||
// assign issue to both projects (each project uses its own default column)
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 2)
|
||||
assert.ElementsMatch(t, []int64{project1.ID, project2.ID}, []int64{issue1.Projects[0].ID, issue1.Projects[1].ID}, "Issue should be in both projects")
|
||||
|
||||
// test issue's project column map
|
||||
projectColumnMap, err := issue1.ProjectColumnMap(t.Context())
|
||||
p1Col, _ := project1.MustDefaultColumn(t.Context())
|
||||
p2Col, _ := project2.MustDefaultColumn(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, p1Col.ID, projectColumnMap[project1.ID])
|
||||
assert.Equal(t, p2Col.ID, projectColumnMap[project2.ID])
|
||||
|
||||
// only keep project2
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
|
||||
|
||||
// also test ResetAttributesLoaded
|
||||
issue1.Projects = nil
|
||||
issue1.ResetAttributesLoaded()
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
|
||||
|
||||
// remove issue's projects
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, issue1.Projects)
|
||||
})
|
||||
|
||||
t.Run("QueryByMultipleProjectIDs", func(t *testing.T) {
|
||||
// Get test data
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// Create three projects
|
||||
var projects []*project_model.Project
|
||||
for i := 1; i <= 3; i++ {
|
||||
project := &project_model.Project{
|
||||
Title: fmt.Sprintf("Query Test Project %d", i),
|
||||
RepoID: issue1.RepoID,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeBasicKanban,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), project))
|
||||
projects = append(projects, project)
|
||||
defer func(id int64) {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), id)
|
||||
}(project.ID)
|
||||
}
|
||||
|
||||
// Assign issue1 to projects 1 and 2
|
||||
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assign issue2 to project 3
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Query for issues in project 3 only (should find issue2)
|
||||
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
|
||||
RepoIDs: []int64{issue1.RepoID},
|
||||
ProjectIDs: []int64{projects[2].ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, issues, "Should find issues in project 3")
|
||||
|
||||
// Verify issue2 is in the results
|
||||
foundIssue2 := false
|
||||
for _, issue := range issues {
|
||||
if issue.ID == issue2.ID {
|
||||
foundIssue2 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundIssue2, "Issue 2 should be found when querying project 3")
|
||||
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. It should use "AND" but not "OR".
|
||||
// Clean up
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -9,16 +9,16 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
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/container"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const ScopeSortPrefix = "scope-"
|
||||
@@ -36,8 +36,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
||||
ReviewedID int64
|
||||
SubscriberID int64
|
||||
MilestoneIDs []int64
|
||||
ProjectID int64
|
||||
ProjectColumnID int64
|
||||
ProjectIDs []int64
|
||||
IsClosed optional.Option[bool]
|
||||
IsPull optional.Option[bool]
|
||||
LabelIDs []int64
|
||||
@@ -71,7 +70,7 @@ func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOption
|
||||
|
||||
// applySorts sort an issues-related session based on the provided
|
||||
// sortType string
|
||||
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
|
||||
func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
|
||||
// Since this sortType is dynamically created, it has to be treated specially.
|
||||
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
||||
scope := after
|
||||
@@ -129,7 +128,7 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyLimit(sess *xorm.Session, opts *IssuesOptions) {
|
||||
func applyLimit(sess db.Session, opts *IssuesOptions) {
|
||||
if opts.Paginator == nil || opts.Paginator.IsListAll() {
|
||||
return
|
||||
}
|
||||
@@ -141,7 +140,7 @@ func applyLimit(sess *xorm.Session, opts *IssuesOptions) {
|
||||
sess.Limit(opts.Paginator.PageSize, start)
|
||||
}
|
||||
|
||||
func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
func applyLabelsCondition(sess db.Session, opts *IssuesOptions) {
|
||||
if len(opts.LabelIDs) > 0 {
|
||||
if opts.LabelIDs[0] == 0 {
|
||||
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)")
|
||||
@@ -182,7 +181,7 @@ func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
func applyMilestoneCondition(sess db.Session, opts *IssuesOptions) {
|
||||
if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID {
|
||||
sess.And("issue.milestone_id = 0")
|
||||
} else if len(opts.MilestoneIDs) > 0 {
|
||||
@@ -197,28 +196,21 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if opts.ProjectID > 0 { // specific project
|
||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
|
||||
And("project_issue.project_id=?", opts.ProjectID)
|
||||
} else if opts.ProjectID == db.NoConditionID { // show those that are in no project
|
||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0})))
|
||||
func applyProjectCondition(sess db.Session, opts *IssuesOptions) {
|
||||
projectIDs := util.SliceRemoveAll(opts.ProjectIDs, 0)
|
||||
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
|
||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
|
||||
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
|
||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
|
||||
} else if len(projectIDs) > 1 { // multiple projects
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
|
||||
}
|
||||
// opts.ProjectID == 0 means all projects,
|
||||
// empty projectIDs means all projects,
|
||||
// do not need to apply any condition
|
||||
}
|
||||
|
||||
func applyProjectColumnCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
// opts.ProjectColumnID == 0 means all project columns,
|
||||
// do not need to apply any condition
|
||||
if opts.ProjectColumnID > 0 {
|
||||
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectColumnID}))
|
||||
} else if opts.ProjectColumnID == db.NoConditionID {
|
||||
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
|
||||
}
|
||||
}
|
||||
|
||||
func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
|
||||
if len(opts.RepoIDs) == 1 {
|
||||
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
|
||||
} else if len(opts.RepoIDs) > 1 {
|
||||
@@ -228,14 +220,14 @@ func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if opts.RepoCond == nil {
|
||||
opts.RepoCond = builder.NewCond()
|
||||
}
|
||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false})))
|
||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
|
||||
}
|
||||
if opts.RepoCond != nil {
|
||||
sess.And(opts.RepoCond)
|
||||
}
|
||||
}
|
||||
|
||||
func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
func applyConditions(sess db.Session, opts *IssuesOptions) {
|
||||
if len(opts.IssueIDs) > 0 {
|
||||
sess.In("issue.id", opts.IssueIDs)
|
||||
}
|
||||
@@ -276,8 +268,6 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
|
||||
applyProjectCondition(sess, opts)
|
||||
|
||||
applyProjectColumnCondition(sess, opts)
|
||||
|
||||
if opts.IsPull.Has() {
|
||||
sess.And("issue.is_pull=?", opts.IsPull.Value())
|
||||
}
|
||||
@@ -371,7 +361,7 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_mod
|
||||
return cond
|
||||
}
|
||||
|
||||
func applyAssigneeCondition(sess *xorm.Session, assigneeID string) {
|
||||
func applyAssigneeCondition(sess db.Session, assigneeID string) {
|
||||
// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
|
||||
if assigneeID == "(none)" {
|
||||
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
|
||||
@@ -383,7 +373,7 @@ func applyAssigneeCondition(sess *xorm.Session, assigneeID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyPosterCondition(sess *xorm.Session, posterID string) {
|
||||
func applyPosterCondition(sess db.Session, posterID string) {
|
||||
// Actually every issue has a poster.
|
||||
// The "(none)" is for internal usage only: when doer tries to search non-existing user as poster, use "(none)" to return empty result.
|
||||
if posterID == "(none)" {
|
||||
@@ -393,13 +383,13 @@ func applyPosterCondition(sess *xorm.Session, posterID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyMentionedCondition(sess *xorm.Session, mentionedID int64) {
|
||||
func applyMentionedCondition(sess db.Session, mentionedID int64) {
|
||||
sess.Join("INNER", "issue_user", "issue.id = issue_user.issue_id").
|
||||
And("issue_user.is_mentioned = ?", true).
|
||||
And("issue_user.uid = ?", mentionedID)
|
||||
}
|
||||
|
||||
func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64) {
|
||||
func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
|
||||
existInTeamQuery := builder.Select("team_user.team_id").
|
||||
From("team_user").
|
||||
Where(builder.Eq{"team_user.uid": reviewRequestedID})
|
||||
@@ -424,7 +414,7 @@ func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64)
|
||||
And(builder.In("issue.id", subQuery))
|
||||
}
|
||||
|
||||
func applyReviewedCondition(sess *xorm.Session, reviewedID int64) {
|
||||
func applyReviewedCondition(sess db.Session, reviewedID int64) {
|
||||
// Query for pull requests where you are a reviewer or commenter, excluding
|
||||
// any pull requests already returned by the review requested filter.
|
||||
notPoster := builder.Neq{"issue.poster_id": reviewedID}
|
||||
@@ -454,7 +444,7 @@ func applyReviewedCondition(sess *xorm.Session, reviewedID int64) {
|
||||
sess.And(notPoster, builder.Or(reviewed, commented))
|
||||
}
|
||||
|
||||
func applySubscribedCondition(sess *xorm.Session, subscriberID int64) {
|
||||
func applySubscribedCondition(sess db.Session, subscriberID int64) {
|
||||
sess.And(
|
||||
builder.
|
||||
NotIn("issue.id",
|
||||
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"gitea.dev/models/db"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// IssueStats represents issue statistic information.
|
||||
@@ -35,12 +34,10 @@ const (
|
||||
FilterModeYourRepositories
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxQueryParameters represents the max query parameters
|
||||
// When queries are broken down in parts because of the number
|
||||
// of parameters, attempt to break by this amount
|
||||
MaxQueryParameters = 300
|
||||
)
|
||||
// MaxQueryParameters represents the max query parameters
|
||||
// When queries are broken down in parts because of the number
|
||||
// of parameters, attempt to break by this amount
|
||||
var MaxQueryParameters = 300
|
||||
|
||||
// CountIssuesByRepo map from repoID to number of issues matching the options
|
||||
func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) {
|
||||
@@ -131,7 +128,7 @@ func getIssueStatsChunk(ctx context.Context, opts *IssuesOptions, issueIDs []int
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int64) *xorm.Session {
|
||||
func applyIssuesOptions(sess db.Session, opts *IssuesOptions, issueIDs []int64) db.Session {
|
||||
if len(opts.RepoIDs) > 1 {
|
||||
sess.In("issue.repo_id", opts.RepoIDs)
|
||||
} else if len(opts.RepoIDs) == 1 {
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"xorm.io/builder"
|
||||
@@ -297,14 +298,11 @@ func TestIssue_ResolveMentions(t *testing.T) {
|
||||
|
||||
func TestResourceIndex(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
for i := range 10 {
|
||||
wg.Go(func() {
|
||||
testInsertIssue(t, fmt.Sprintf("issue %d", i+1), "my issue", 0)
|
||||
wg.Done()
|
||||
}(i)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -317,18 +315,12 @@ func TestCorrectIssueStats(t *testing.T) {
|
||||
// maxQueryParameters + 10 issues into the testDatabase.
|
||||
// Each new issues will have a constant description "Bugs are nasty"
|
||||
// Which will be used later on.
|
||||
|
||||
defer test.MockVariableValue(&issues_model.MaxQueryParameters, 25)()
|
||||
issueAmount := issues_model.MaxQueryParameters + 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range issueAmount {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
testInsertIssue(t, fmt.Sprintf("Issue %d", i+1), "Bugs are nasty", 0)
|
||||
wg.Done()
|
||||
}(i)
|
||||
testInsertIssue(t, fmt.Sprintf("Issue %d", i+1), "Bugs are nasty", 0)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Now we will get all issueID's that match the "Bugs are nasty" query.
|
||||
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
|
||||
@@ -424,10 +416,10 @@ func TestIssueLoadAttributes(t *testing.T) {
|
||||
}
|
||||
if issue.ID == int64(1) {
|
||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||
assert.NotNil(t, issue.Project)
|
||||
assert.Equal(t, int64(1), issue.Project.ID)
|
||||
assert.NotEmpty(t, issue.Projects)
|
||||
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||
} else {
|
||||
assert.Nil(t, issue.Project)
|
||||
assert.Empty(t, issue.Projects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
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/git"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/references"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -93,12 +93,6 @@ type ErrIssueIsOpen struct {
|
||||
Index int64
|
||||
}
|
||||
|
||||
// IsErrIssueIsOpen checks if an error is a ErrIssueIsOpen.
|
||||
func IsErrIssueIsOpen(err error) bool {
|
||||
_, ok := err.(ErrIssueIsOpen)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIssueIsOpen) Error() string {
|
||||
return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already open", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
|
||||
}
|
||||
@@ -269,14 +263,46 @@ func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *
|
||||
return err
|
||||
}
|
||||
|
||||
// validateAttachmentForIssue rejects a foreign or already-linked attachment before it is linked to
|
||||
// issue: a known UUID could otherwise re-link (and expose) another repo's private attachment. A
|
||||
// legacy attachment predating repo_id-on-upload is adopted into the issue's repo.
|
||||
func validateAttachmentForIssue(ctx context.Context, issue *Issue, attachment *repo_model.Attachment) error {
|
||||
if attachment.RepoID == 0 && attachment.CreatedUnix < repo_model.LegacyAttachmentMissingRepoIDCutoff {
|
||||
attachment.RepoID = issue.RepoID
|
||||
if err := repo_model.UpdateAttachmentByUUID(ctx, attachment, "repo_id"); err != nil {
|
||||
return fmt.Errorf("update attachment repo_id [id: %d]: %w", attachment.ID, err)
|
||||
}
|
||||
}
|
||||
if attachment.RepoID != issue.RepoID {
|
||||
return util.NewPermissionDeniedErrorf("attachment belongs to a different repository")
|
||||
}
|
||||
if attachment.IssueID != 0 && attachment.IssueID != issue.ID {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to another issue")
|
||||
}
|
||||
if attachment.ReleaseID != 0 {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to a release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIssueAttachments update attachments by UUIDs for the issue
|
||||
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = issueID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
|
||||
@@ -441,7 +467,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la
|
||||
LabelIDs: labelIDs,
|
||||
Attachments: uuids,
|
||||
}); err != nil {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("newIssue: %w", err)
|
||||
@@ -605,7 +631,7 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
|
||||
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
|
||||
continue
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
|
||||
has, err := db.Exist[organization.TeamUnit](ctx, builder.Eq{"org_id": issue.Repo.Owner.ID, "team_id": team.ID, "`type`": unittype})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateIssueAttachmentsCrossRepo(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// attachment id 2 belongs to repo 2 / issue 4; issue 1 lives in repo 1
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
foreign := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
require.NotEqual(t, issue1.RepoID, foreign.RepoID)
|
||||
|
||||
// re-linking a foreign repo's attachment by UUID must be rejected
|
||||
err := issues_model.UpdateIssueAttachments(t.Context(), issue1.ID, []string{foreign.UUID})
|
||||
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||
|
||||
// the foreign attachment must be left untouched
|
||||
reloaded := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
assert.Equal(t, foreign.IssueID, reloaded.IssueID)
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
)
|
||||
|
||||
// IssueUser represents an issue-user relation.
|
||||
|
||||
@@ -6,10 +6,10 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -6,10 +6,10 @@ package issues
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// IssueWatch is connection request for receiving issue notification.
|
||||
@@ -67,7 +67,7 @@ func GetIssueWatch(ctx context.Context, userID, issueID int64) (iw *IssueWatch,
|
||||
return iw, exists, err
|
||||
}
|
||||
|
||||
// CheckIssueWatch check if an user is watching an issue
|
||||
// CheckIssueWatch check if a user is watching an issue
|
||||
// it takes participants and repo watch into account
|
||||
func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (bool, error) {
|
||||
iw, exist, err := GetIssueWatch(ctx, user.ID, issue.ID)
|
||||
@@ -106,7 +106,7 @@ func GetIssueWatchers(ctx context.Context, issueID int64, listOptions db.ListOpt
|
||||
Join("INNER", "`user`", "`user`.id = `issue_watch`.user_id")
|
||||
|
||||
if listOptions.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &listOptions)
|
||||
db.SetSessionPagination(sess, &listOptions)
|
||||
watches := make([]*IssueWatch, 0, listOptions.PageSize)
|
||||
return watches, sess.Find(&watches)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"gitea.dev/models/db"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type crossReference struct {
|
||||
@@ -189,11 +191,11 @@ func (issue *Issue) updateCrossReferenceList(list []*crossReference, xref *cross
|
||||
func (issue *Issue) verifyReferencedIssue(stdCtx context.Context, ctx *crossReferencesContext, repo *repo_model.Repository,
|
||||
ref references.IssueReference,
|
||||
) (*Issue, references.XRefAction, error) {
|
||||
refIssue := &Issue{RepoID: repo.ID, Index: ref.Index}
|
||||
refAction := ref.Action
|
||||
e := db.GetEngine(stdCtx)
|
||||
|
||||
if has, _ := e.Get(refIssue); !has {
|
||||
refIssue, has, err := db.Get[Issue](stdCtx, builder.Eq{"repo_id": repo.ID, "`index`": ref.Index})
|
||||
if err != nil {
|
||||
return nil, references.XRefActionNone, err
|
||||
} else if !has {
|
||||
return nil, references.XRefActionNone, nil
|
||||
}
|
||||
if err := refIssue.LoadRepo(stdCtx); err != nil {
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/references"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -5,18 +5,21 @@
|
||||
package issues
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/label"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/label"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -27,12 +30,6 @@ type ErrRepoLabelNotExist struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
|
||||
func IsErrRepoLabelNotExist(err error) bool {
|
||||
_, ok := err.(ErrRepoLabelNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoLabelNotExist) Error() string {
|
||||
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
|
||||
}
|
||||
@@ -196,6 +193,42 @@ func (l *Label) ExclusiveScope() string {
|
||||
return l.Name[:lastIndex]
|
||||
}
|
||||
|
||||
// CompareLabelForDisplay compares labels for displaying them in dropdowns or lists.
|
||||
// Labels are grouped by their exclusive scope, and labels within the same scope
|
||||
// are sorted by their exclusive order, where unordered labels (order 0) come last.
|
||||
// Labels without a scope are listed first and everything else falls back to name order.
|
||||
func CompareLabelForDisplay(a, b *Label) int {
|
||||
scopeA, scopeB := a.ExclusiveScope(), b.ExclusiveScope()
|
||||
if scopeA != scopeB {
|
||||
if scopeA == "" {
|
||||
return -1
|
||||
}
|
||||
if scopeB == "" {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(scopeA, scopeB)
|
||||
}
|
||||
if scopeA != "" {
|
||||
orderA, orderB := a.ExclusiveOrder, b.ExclusiveOrder
|
||||
if orderA <= 0 {
|
||||
orderA = math.MaxInt
|
||||
}
|
||||
if orderB <= 0 {
|
||||
orderB = math.MaxInt
|
||||
}
|
||||
if orderA != orderB {
|
||||
return cmp.Compare(orderA, orderB)
|
||||
}
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// SortLabelsForDisplay sorts labels in place for displaying them in dropdowns or lists,
|
||||
// grouping them by their exclusive scope and respecting the exclusive order within each scope.
|
||||
func SortLabelsForDisplay(labels []*Label) {
|
||||
slices.SortStableFunc(labels, CompareLabelForDisplay)
|
||||
}
|
||||
|
||||
// NewLabel creates a new label
|
||||
func NewLabel(ctx context.Context, l *Label) error {
|
||||
color, err := label.NormalizeColor(l.Color)
|
||||
@@ -312,6 +345,18 @@ func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetLabelInRepoOrOrgByID returns the label with labelID scoped to the repo, falling back to the
|
||||
// repo's owning organization when ownerIsOrg is set. It returns ErrRepoLabelNotExist /
|
||||
// ErrOrgLabelNotExist when the label is in neither scope, so a foreign-but-existing label ID is
|
||||
// indistinguishable from a nonexistent one (no cross-repo enumeration oracle).
|
||||
func GetLabelInRepoOrOrgByID(ctx context.Context, repoID, ownerID int64, ownerIsOrg bool, labelID int64) (*Label, error) {
|
||||
label, err := GetLabelInRepoByID(ctx, repoID, labelID)
|
||||
if err != nil && errors.Is(err, util.ErrNotExist) && ownerIsOrg {
|
||||
return GetLabelInOrgByID(ctx, ownerID, labelID)
|
||||
}
|
||||
return label, err
|
||||
}
|
||||
|
||||
// GetLabelInRepoByID returns a label by ID in given repository.
|
||||
func GetLabelInRepoByID(ctx context.Context, repoID, labelID int64) (*Label, error) {
|
||||
if labelID <= 0 || repoID <= 0 {
|
||||
@@ -396,7 +441,7 @@ func GetLabelsByRepoID(ctx context.Context, repoID int64, sortType string, listO
|
||||
}
|
||||
|
||||
if listOptions.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &listOptions)
|
||||
db.SetSessionPagination(sess, &listOptions)
|
||||
}
|
||||
|
||||
return labels, sess.Find(&labels)
|
||||
@@ -471,7 +516,7 @@ func GetLabelsByOrgID(ctx context.Context, orgID int64, sortType string, listOpt
|
||||
}
|
||||
|
||||
if listOptions.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &listOptions)
|
||||
db.SetSessionPagination(sess, &listOptions)
|
||||
}
|
||||
|
||||
return labels, sess.Find(&labels)
|
||||
|
||||
@@ -6,12 +6,13 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -53,6 +54,47 @@ func TestLabel_ExclusiveScope(t *testing.T) {
|
||||
assert.Equal(t, "scope/subscope", label.ExclusiveScope())
|
||||
}
|
||||
|
||||
func TestSortLabelsForDisplay(t *testing.T) {
|
||||
labels := []*issues_model.Label{
|
||||
{Name: "priority/low", Exclusive: true, ExclusiveOrder: 4},
|
||||
{Name: "priority/critical", Exclusive: true, ExclusiveOrder: 1},
|
||||
{Name: "priority/medium", Exclusive: true, ExclusiveOrder: 3},
|
||||
{Name: "priority/high", Exclusive: true, ExclusiveOrder: 2},
|
||||
{Name: "bug"},
|
||||
{Name: "enhancement"},
|
||||
{Name: "kind/question", Exclusive: true},
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(labels)
|
||||
|
||||
names := make([]string, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
names = append(names, l.Name)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"bug",
|
||||
"enhancement",
|
||||
"kind/question",
|
||||
"priority/critical",
|
||||
"priority/high",
|
||||
"priority/medium",
|
||||
"priority/low",
|
||||
}, names)
|
||||
|
||||
// labels without an exclusive order in the same scope are listed last, ordered by name
|
||||
labels = []*issues_model.Label{
|
||||
{Name: "scope/unordered-b", Exclusive: true},
|
||||
{Name: "scope/ordered", Exclusive: true, ExclusiveOrder: 1},
|
||||
{Name: "scope/unordered-a", Exclusive: true},
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(labels)
|
||||
|
||||
names = names[:0]
|
||||
for _, l := range labels {
|
||||
names = append(names, l.Name)
|
||||
}
|
||||
assert.Equal(t, []string{"scope/ordered", "scope/unordered-a", "scope/unordered-b"}, names)
|
||||
}
|
||||
|
||||
func TestNewLabels(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
labels := []*issues_model.Label{
|
||||
@@ -94,10 +136,10 @@ func TestGetLabelInRepoByName(t *testing.T) {
|
||||
assert.Equal(t, "label1", label.Name)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), 1, "")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), unittest.NonexistentID, "nonexistent")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelInRepoByNames(t *testing.T) {
|
||||
@@ -131,10 +173,10 @@ func TestGetLabelInRepoByID(t *testing.T) {
|
||||
assert.EqualValues(t, 1, label.ID)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), 1, -1)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelsInRepoByIDs(t *testing.T) {
|
||||
|
||||
@@ -6,14 +6,14 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "code.gitea.io/gitea/models"
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
_ "code.gitea.io/gitea/models/activities"
|
||||
_ "code.gitea.io/gitea/models/repo"
|
||||
_ "code.gitea.io/gitea/models/user"
|
||||
_ "gitea.dev/models"
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/activities"
|
||||
_ "gitea.dev/models/repo"
|
||||
_ "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/optional"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -10,20 +10,21 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
pull_model "code.gitea.io/gitea/models/pull"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
org_model "gitea.dev/models/organization"
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -413,7 +414,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
|
||||
}
|
||||
|
||||
// GetGitHeadRefName returns git ref for hidden pull request branch
|
||||
func (pr *PullRequest) GetGitHeadRefName() string {
|
||||
func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefName but not string
|
||||
return fmt.Sprintf("%s%d/head", git.PullPrefix, pr.Index)
|
||||
}
|
||||
|
||||
@@ -437,8 +438,8 @@ func (pr *PullRequest) IsChecking() bool {
|
||||
return pr.Status == PullRequestStatusChecking
|
||||
}
|
||||
|
||||
// CanAutoMerge returns true if this pull request can be merged automatically.
|
||||
func (pr *PullRequest) CanAutoMerge() bool {
|
||||
// IsStatusMergeable returns true if this pull request is mergeable to its base
|
||||
func (pr *PullRequest) IsStatusMergeable() bool {
|
||||
return pr.Status == PullRequestStatusMergeable
|
||||
}
|
||||
|
||||
@@ -475,7 +476,7 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss
|
||||
LabelIDs: labelIDs,
|
||||
Attachments: uuids,
|
||||
}); err != nil {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("newIssue: %w", err)
|
||||
@@ -534,12 +535,8 @@ func GetPullRequestByIndex(ctx context.Context, repoID, index int64) (*PullReque
|
||||
if index < 1 {
|
||||
return nil, ErrPullRequestNotExist{}
|
||||
}
|
||||
pr := &PullRequest{
|
||||
BaseRepoID: repoID,
|
||||
Index: index,
|
||||
}
|
||||
|
||||
has, err := db.GetEngine(ctx).Get(pr)
|
||||
pr, has, err := db.Get[PullRequest](ctx, builder.Eq{"base_repo_id": repoID, "`index`": index})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -860,6 +857,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
||||
return rules, warnings
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
@@ -888,6 +890,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
for _, user := range tokens[1:] {
|
||||
user = strings.TrimPrefix(user, "@")
|
||||
|
||||
@@ -7,18 +7,17 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// PullRequestsOptions holds the options for PRs
|
||||
@@ -32,7 +31,7 @@ type PullRequestsOptions struct {
|
||||
BaseBranch string
|
||||
}
|
||||
|
||||
func listPullRequestStatement(ctx context.Context, baseRepoID int64, opts *PullRequestsOptions) *xorm.Session {
|
||||
func listPullRequestStatement(ctx context.Context, baseRepoID int64, opts *PullRequestsOptions) db.Session {
|
||||
sess := db.GetEngine(ctx).Where("pull_request.base_repo_id=?", baseRepoID)
|
||||
|
||||
if opts.BaseBranch != "" {
|
||||
@@ -182,7 +181,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio
|
||||
|
||||
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
|
||||
applySorts(findSession, opts.SortType, 0)
|
||||
findSession = db.SetSessionPagination(findSession, opts)
|
||||
db.SetSessionPagination(findSession, opts)
|
||||
prs := make([]*PullRequest, 0, opts.PageSize)
|
||||
found := findSession.Find(&prs)
|
||||
return prs, maxResults, found
|
||||
|
||||
@@ -6,13 +6,13 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -376,6 +379,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||
// each compiled rule must enforce a bounded match time.
|
||||
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||
start := time.Now()
|
||||
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||
}
|
||||
|
||||
func testGetApprovers(t *testing.T) {
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -165,7 +165,7 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList
|
||||
In("reaction.`type`", setting.UI.Reactions).
|
||||
Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id")
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &opts)
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
|
||||
reactions := make([]*Reaction, 0, opts.PageSize)
|
||||
count, err := sess.FindAndCount(&reactions)
|
||||
|
||||
@@ -10,16 +10,16 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -67,7 +67,7 @@ func (err ErrNotValidReviewRequest) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR.
|
||||
// ErrReviewRequestOnClosedPR represents an error when a user tries to request a re-review on a closed or merged PR.
|
||||
type ErrReviewRequestOnClosedPR struct{}
|
||||
|
||||
// IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR.
|
||||
@@ -324,6 +324,59 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
}
|
||||
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
|
||||
// and reject reviews of an issue against its pull request's current base branch.
|
||||
// It must be called whenever the target branch changes, otherwise an approval that
|
||||
// was official on the previous (possibly unprotected) branch would keep satisfying
|
||||
// the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clearing and restoring the official flags must happen atomically, otherwise a
|
||||
// failure in between would leave the reviews without any official flag set.
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Only the latest approve/reject review of each reviewer counts as official, so
|
||||
// clear the flag on all of them first and restore it only where it still applies.
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("issue_id = ?", issue.ID).
|
||||
In("type", ReviewTypeApprove, ReviewTypeReject).
|
||||
Cols("official").
|
||||
Update(&Review{Official: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
|
||||
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, review := range reviews {
|
||||
if err := review.LoadReviewer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if review.Reviewer == nil {
|
||||
continue
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if official {
|
||||
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateReview creates a new review based on opts
|
||||
func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Review, error) {
|
||||
@@ -483,6 +536,14 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// make sure the leftover review request is cleared, consistent with CreateReview
|
||||
if reviewType != ReviewTypePending {
|
||||
if _, err := sess.Where(builder.Eq{"reviewer_id": doer.ID, "issue_id": issue.ID, "type": ReviewTypeRequest}).
|
||||
Delete(new(Review)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comm, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
@@ -900,8 +961,8 @@ func MarkConversation(ctx context.Context, comment *Comment, doer *user_model.Us
|
||||
// CanMarkConversation Add or remove Conversation mark for a code comment permission check
|
||||
// the PR writer , official reviewer and poster can do it
|
||||
func CanMarkConversation(ctx context.Context, issue *Issue, doer *user_model.User) (permResult bool, err error) {
|
||||
if doer == nil || issue == nil {
|
||||
return false, errors.New("issue or doer is nil")
|
||||
if doer == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err = issue.LoadRepo(ctx); err != nil {
|
||||
@@ -1004,19 +1065,23 @@ func (r *Review) GetCodeCommentsCount(ctx context.Context) int {
|
||||
return int(count)
|
||||
}
|
||||
|
||||
// HTMLURL formats a URL-string to the related review issue-comment
|
||||
// HashTag returns unique hash tag for review.
|
||||
func (r *Review) HashTag() string {
|
||||
return fmt.Sprintf("pullrequestreview-%d", r.ID)
|
||||
}
|
||||
|
||||
// HTMLURL formats a URL-string to the review on the pull request page
|
||||
func (r *Review) HTMLURL(ctx context.Context) string {
|
||||
opts := FindCommentsOptions{
|
||||
Type: CommentTypeReview,
|
||||
IssueID: r.IssueID,
|
||||
ReviewID: r.ID,
|
||||
if r.Type != ReviewTypeApprove && r.Type != ReviewTypeComment && r.Type != ReviewTypeReject {
|
||||
return "" // only submitted reviews get a timeline block carrying the anchor
|
||||
}
|
||||
comment := new(Comment)
|
||||
has, err := db.GetEngine(ctx).Where(opts.ToConds()).Get(comment)
|
||||
if err != nil || !has {
|
||||
if err := r.LoadIssue(ctx); err != nil {
|
||||
return ""
|
||||
}
|
||||
return comment.HTMLURL(ctx)
|
||||
if err := r.Issue.LoadRepo(ctx); err != nil {
|
||||
return ""
|
||||
}
|
||||
return r.Issue.HTMLURL(ctx) + "#" + r.HashTag()
|
||||
}
|
||||
|
||||
// RemapExternalUser ExternalUserRemappable interface
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
organization_model "code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"gitea.dev/models/db"
|
||||
organization_model "gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -121,7 +121,7 @@ func FindReviews(ctx context.Context, opts FindReviewOptions) (ReviewList, error
|
||||
reviews := make([]*Review, 0, 10)
|
||||
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||
if opts.Page > 0 && !opts.IsListAll() {
|
||||
sess = db.SetSessionPagination(sess, &opts)
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
}
|
||||
return reviews, sess.
|
||||
Asc("created_unix").
|
||||
@@ -135,7 +135,7 @@ func FindLatestReviews(ctx context.Context, opts FindReviewOptions) (ReviewList,
|
||||
cond := opts.toCond()
|
||||
sess := db.GetEngine(ctx).Where(cond)
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &opts)
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
}
|
||||
|
||||
sess.In("id", builder.
|
||||
|
||||
@@ -6,10 +6,13 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
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"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -26,6 +29,15 @@ func TestGetReviewByID(t *testing.T) {
|
||||
assert.True(t, issues_model.IsErrReviewNotExist(err), "IsErrReviewNotExist")
|
||||
}
|
||||
|
||||
func TestReview_HTMLURL(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
|
||||
assert.Equal(t, setting.AppURL+"user2/repo1/pulls/2#pullrequestreview-1", review.HTMLURL(t.Context()))
|
||||
|
||||
pendingReview := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 4})
|
||||
assert.Empty(t, pendingReview.HTMLURL(t.Context()))
|
||||
}
|
||||
|
||||
func TestReview_LoadAttributes(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
|
||||
@@ -303,6 +315,46 @@ func TestDeleteDismissedReview(t *testing.T) {
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
|
||||
}
|
||||
|
||||
func TestSubmitReviewClearsStaleReviewRequest(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
assert.NoError(t, issue.LoadRepo(t.Context()))
|
||||
assert.NoError(t, issue.Repo.LoadOwner(t.Context()))
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// the reviewer is requested to review the pull request
|
||||
requestReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypeRequest,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// the reviewer starts a pending review (e.g. by adding code comments)
|
||||
pendingReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypePending,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// submitting the pending review must clear the leftover review request,
|
||||
// otherwise the reviewer can no longer be re-requested afterwards
|
||||
review, _, err := issues_model.SubmitReview(t.Context(), reviewer, issue, issues_model.ReviewTypeComment, "looks good", "", false, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, pendingReview.ID, review.ID)
|
||||
assert.Equal(t, issues_model.ReviewTypeComment, review.Type)
|
||||
|
||||
unittest.AssertNotExistsBean(t, &issues_model.Review{ID: requestReview.ID})
|
||||
|
||||
// the reviewer can be re-requested afterwards (no-op before the fix)
|
||||
comment, err := issues_model.AddReviewRequest(t.Context(), issue, reviewer, doer, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, comment)
|
||||
}
|
||||
|
||||
func TestAddReviewRequest(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
@@ -346,3 +398,45 @@ func TestAddReviewRequest(t *testing.T) {
|
||||
assert.NotNil(t, comment.CommentMetaData)
|
||||
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
|
||||
}
|
||||
|
||||
func TestRecalculateReviewsOfficial(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// PR #2 targets repo1's "master" branch. Simulate an approval that became
|
||||
// official while the PR targeted an unprotected branch.
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypeApprove,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
Official: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Protect the (now current) target branch with an approvals whitelist that
|
||||
// does not include the reviewer, mirroring a retarget onto a protected branch.
|
||||
rule := &git_model.ProtectedBranch{
|
||||
RepoID: issue.RepoID,
|
||||
RuleName: "master",
|
||||
EnableApprovalsWhitelist: true,
|
||||
ApprovalsWhitelistUserIDs: []int64{2},
|
||||
RequiredApprovals: 1,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), rule))
|
||||
|
||||
// Re-evaluating must strip the stale official flag, otherwise the approval
|
||||
// would still satisfy the protected branch's required approvals.
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.False(t, review.Official)
|
||||
|
||||
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
|
||||
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
|
||||
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.True(t, review.Official)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -77,7 +77,7 @@ func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOp
|
||||
sws := make([]*Stopwatch, 0, 8)
|
||||
sess := db.GetEngine(ctx).Where("stopwatch.user_id = ?", userID)
|
||||
if listOptions.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, &listOptions)
|
||||
db.SetSessionPagination(sess, &listOptions)
|
||||
}
|
||||
|
||||
err := sess.Find(&sws)
|
||||
|
||||
@@ -6,9 +6,9 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,14 +9,13 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// TrackedTime represents a time that was spent for a specific issue.
|
||||
@@ -140,7 +139,7 @@ func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine {
|
||||
sess = sess.Where(opts.ToConds())
|
||||
|
||||
if opts.Page > 0 {
|
||||
sess = db.SetSessionPagination(sess, opts)
|
||||
db.SetSessionPagination(sess, opts)
|
||||
}
|
||||
|
||||
return sess
|
||||
@@ -344,7 +343,7 @@ func GetIssueTotalTrackedTime(ctx context.Context, opts *IssuesOptions, isClosed
|
||||
}
|
||||
|
||||
func getIssueTotalTrackedTimeChunk(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool], issueIDs []int64) (int64, error) {
|
||||
sumSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
|
||||
sumSession := func(opts *IssuesOptions, issueIDs []int64) db.Session {
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("tracked_time").
|
||||
Where("tracked_time.deleted = ?", false).
|
||||
@@ -359,7 +358,7 @@ func getIssueTotalTrackedTimeChunk(ctx context.Context, opts *IssuesOptions, isC
|
||||
|
||||
session := sumSession(opts, issueIDs)
|
||||
if isClosed.Has() {
|
||||
session = session.And("issue.is_closed = ?", isClosed.Value())
|
||||
session.And("issue.is_closed = ?", isClosed.Value())
|
||||
}
|
||||
return session.SumInt(new(trackedTime), "tracked_time.time")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user