Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
18aa6fe
Added multi-project feature
tyroneyeh May 7, 2025
b64fcea
Fix board column move issue
tyroneyeh May 7, 2025
abfac6f
Try fixing your unit tests
tyroneyeh May 7, 2025
8f2483d
Fix lint issue
tyroneyeh May 7, 2025
020aa0d
Fix add issue to multiple project issue
tyroneyeh May 7, 2025
db2704f
Remove lessuse code
tyroneyeh May 7, 2025
8352a06
Fix search empty issue
tyroneyeh May 7, 2025
9363b0c
Improject filter search method
tyroneyeh May 7, 2025
8758420
Try fix unit test fail
tyroneyeh May 7, 2025
3fe8491
Try fix unit test issue
tyroneyeh May 7, 2025
d7569fd
Fix no project filter list issue
tyroneyeh May 8, 2025
56e3dcd
Fix unit test on TestIssueList_LoadAttributes
tyroneyeh May 8, 2025
62b5db4
Fix unit test on Test_Projects fail
tyroneyeh May 8, 2025
0065dfa
Fix unit test on TestIssueLoadAttributes
tyroneyeh May 8, 2025
53afa55
Adjuect from Cols to Select
tyroneyeh May 8, 2025
aec0e54
Improve adjust A to B project bug
tyroneyeh May 9, 2025
a4265d7
Improve string join code for projects id
tyroneyeh May 9, 2025
9365b45
Fix on issue list clear project issue
tyroneyeh May 9, 2025
ff29541
Change db.Exec to Update method
tyroneyeh May 9, 2025
a4844eb
Rename LoadProject to LoadProjects
tyroneyeh May 11, 2025
a527007
Rename variable projectIDs to ids
tyroneyeh May 12, 2025
1f23c36
Revert "Rename variable projectIDs to ids"
tyroneyeh May 12, 2025
6d53378
Fix creating new issue redirect link from project
tyroneyeh May 26, 2025
1e84279
Fix LoadProjects function
tyroneyeh May 26, 2025
226c0fb
Modify indexer json field name
tyroneyeh Jul 23, 2025
d0bd2de
Fix unit test
tyroneyeh Jul 24, 2025
fed56be
Fix lint check error
tyroneyeh Jul 24, 2025
7e46251
Fix unit test
tyroneyeh Jul 24, 2025
4c4bb03
[FIX] check to make sure SQL returns result, and without error
icyavocado Feb 14, 2026
3416b08
[FIX] Refactor projectIDs method to handle project retrieval and erro…
icyavocado Feb 14, 2026
aec3e71
[FIX] Rename variable for clarity in IssueAssignOrRemoveProject function
icyavocado Feb 14, 2026
c8723c6
[FIX] Update project list styling to use 'relaxed' class for improved…
icyavocado Feb 16, 2026
95677c2
[FIX] Handle error in LoadProjects when retrieving project IDs
icyavocado Feb 17, 2026
0338162
[FIX] Update projectIDs function to return error on database query fa…
icyavocado Feb 17, 2026
c61f6e3
[FIX] Log warnings for issues that fail to assign projects due to per…
icyavocado Feb 17, 2026
58dfdbc
[FIX] Enhance LoadProjects to handle nil and empty project list cases
icyavocado Feb 17, 2026
a709e0b
[FIX] Handle error when retrieving old project IDs in IssueAssignOrRe…
icyavocado Feb 17, 2026
d047cdb
Add comprehensive test suite for multiple projects per issue function…
icyavocado Feb 25, 2026
2aad2b5
[FIX] Update project_id to project_ids across indexers for consistency
Copilot Feb 18, 2026
6d698e8
[FIX] Update handling of ProjectIDs in ToDBOptions and related tests
Copilot Feb 18, 2026
21316ab
[FIX] Update project ID handling in search queries and increment inde…
icyavocado Feb 25, 2026
b03696c
[FIX] Rename project_id to project_ids for consistency in IndexerData…
icyavocado Feb 25, 2026
69d3f72
[FIX] Simplify ProjectIDs check using slices.Contains in indexer tests
icyavocado Feb 25, 2026
8199f85
[FIX] Replace RemoveValue with SliceRemoveAll for better project ID h…
icyavocado Feb 25, 2026
01b1ff8
[FIX] Refactor project ID handling to use strings.Join for improved c…
icyavocado Feb 25, 2026
3d6ceb0
[FIX] find default column instead of default to uncategorized
icyavocado Feb 25, 2026
a834c35
[FIX] Refactor projectID handling to use explicit projectIDs slice li…
Copilot Feb 25, 2026
96bc253
[FIX] add isProjectsLoaded flag following milestone pattern
Copilot Feb 25, 2026
8f22b88
[FIX] update queries in IssueAssignOrRemoveProject
Copilot Feb 25, 2026
f0f9125
[FIX] using a new isuesProject map to access Projects faster
Copilot Feb 25, 2026
0bef347
[FIX] update documentation from LoadProject to LoadProjects
icyavocado Feb 25, 2026
f8db5c6
[DOCUMENTATION] update documentation to show mulitple projectIds can …
icyavocado Feb 25, 2026
97b4d81
[FIX] remove projectID passing in incorrectly
icyavocado Feb 25, 2026
6217d8d
[DOCUMENTATION] update opts.ProjectID to opts.ProjectIDs
icyavocado Feb 25, 2026
31a7ca1
[FIX] rename variables to projectsToAdd and projectsToRemove
Copilot Feb 26, 2026
1317d3c
[FIX] make DiffSlice preserve input slice order
Copilot Feb 26, 2026
1a65ba1
[FIX] update ProjectIDs declaration
icyavocado Feb 27, 2026
a0cbb87
[FIX] handle projectId NoConditionID
icyavocado Feb 27, 2026
ecf4dcd
[FIX] convert projectID from optional.Option[int64] to []int64 for Pr…
Copilot Feb 27, 2026
d01eef2
[FIX] update to ProjectIDs
icyavocado Feb 27, 2026
6b01e33
[FIX] use projectIDs variable for IndexerData.ProjectIDs in test data…
Copilot Feb 27, 2026
1919cff
[FIX] compute issue-project sorting per (project_id, project_board_id)
Copilot Feb 27, 2026
e0e49de
[FIX] avoid mutating opts.ProjectIDs in applyProjectCondition
Copilot Feb 27, 2026
3845959
[FIX] streamline projectID handling in SearchRepoIssuesJSON
icyavocado Feb 27, 2026
8fe37bf
[FIX] update projectID handling in SearchIssues to support multiple p…
icyavocado Feb 27, 2026
6f9bb32
[FIX] format SearchRepoIssuesJSON parameters for consistency
icyavocado Feb 27, 2026
a5dbb63
[E2E] add e2e test for projects
icyavocado Feb 28, 2026
a5a2af2
[FIX] update class name from milestone list to project-list
icyavocado Feb 28, 2026
b4b07b9
[FIX] update class name from milestone list to project-list in e2e test
icyavocado Feb 28, 2026
eb15fb7
[FIX] reset isProjectsLoaded variable before re-LoadProjects
icyavocado Feb 28, 2026
0edf345
[E2E] remove e2e tests since it will take about 14s to finish
icyavocado Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,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:"-"`
Expand Down Expand Up @@ -327,7 +328,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
}

Expand Down Expand Up @@ -377,6 +378,7 @@ func (issue *Issue) ResetAttributesLoaded() {
issue.isMilestoneLoaded = false
issue.isAttachmentsLoaded = false
issue.isAssigneeLoaded = false
issue.isProjectsLoaded = false
}

// GetIsRead load the `IsRead` field of the issue
Expand Down
7 changes: 4 additions & 3 deletions models/issues/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -207,14 +207,15 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
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
}
Expand Down
6 changes: 3 additions & 3 deletions models/issues/issue_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,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.NotNil(t, issue.Projects[0])
assert.Equal(t, int64(1), issue.Projects[0].ID)
} else {
assert.Nil(t, issue.Project)
assert.Nil(t, issue.Projects)
}
}
}
149 changes: 92 additions & 57 deletions models/issues/issue_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,34 @@ import (
"code.gitea.io/gitea/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).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
func (issue *Issue) projectIDs(ctx context.Context) ([]int64, error) {
var pis []project_model.ProjectIssue
if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&pis); err != nil {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#34386 (comment)

It's not safe to ignore the err, in case the SQL would be wrong.

Catched error and return nill.

return nil, err
}
return ip.ProjectID

if len(pis) == 0 {
return []int64{}, nil
}

ids := make([]int64, 0, len(pis))
for _, pi := range pis {
ids = append(ids, pi.ProjectID)
}
return ids, nil
}

// ProjectColumnID return project column id if issue was assigned to one
Expand Down Expand Up @@ -68,7 +73,7 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i
func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) {
issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
o.ProjectColumnID = b.ID
o.ProjectID = b.ProjectID
o.ProjectIDs = []int64{b.ProjectID}
o.SortType = "project-column-sorting"
}))
if err != nil {
Expand All @@ -78,7 +83,7 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
if b.Default {
issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
o.ProjectColumnID = db.NoConditionID
o.ProjectID = b.ProjectID
o.ProjectIDs = []int64{b.ProjectID}
o.SortType = "project-column-sorting"
}))
if err != nil {
Expand All @@ -94,73 +99,103 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
return issueList, 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 or nil, all projects are removed from the issue.
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64, newColumnID int64) error {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#34386 (comment)

How could it be right?

Why newColumnID could apply to all newProjectIDs?

Target the default column of a project (if exists) defaultColumn, err := newProject.MustDefaultColumn(ctx)

return db.WithTx(ctx, func(ctx context.Context) error {
oldProjectID := issue.projectID(ctx)
oldProjectIDs, err := issue.projectIDs(ctx)
if err != nil {
return err
}

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)
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)

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 {
return nil
}

pi := make([]*project_model.ProjectIssue, 0, len(projectsToAdd))

for _, projectID := range projectsToAdd {
if projectID == 0 {
continue
}
newProject, err := project_model.GetProjectByID(ctx, projectID)
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)

projectColumnID := newColumnID
if projectColumnID == 0 {
defaultColumn, err := newProject.MustDefaultColumn(ctx)
if err != nil {
return err
}
newColumnID = newDefaultColumn.ID
projectColumnID = defaultColumn.ID
}
}

if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
return err
}
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
And("project_id=?", projectID).
And("project_board_id=?", projectColumnID).
Get(&res); err != nil {
return err
}
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)

pi = append(pi, &project_model.ProjectIssue{
IssueID: issue.ID,
ProjectID: projectID,
ProjectColumnID: projectColumnID,
Sorting: newSorting,
})

if oldProjectID > 0 || newProjectID > 0 {
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldProjectID: oldProjectID,
ProjectID: newProjectID,
OldProjectID: 0,
ProjectID: projectID,
}); 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
if len(pi) > 0 {
return db.Insert(ctx, pi)
}
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
})
}
Loading