feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -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)
}
+54 -49
View File
@@ -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"
)
+40 -46
View File
@@ -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"
)
+59 -14
View File
@@ -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"
)
+25 -21
View File
@@ -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, "@")
+11 -12
View File
@@ -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)
+87 -22
View File
@@ -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"
)