Skip to content

Added multi-project feature #34386

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 11 additions & 11 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,17 @@ 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:"-"`
Priority int
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
Expand Down
9 changes: 7 additions & 2 deletions models/issues/issue_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,19 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
return err
}
for _, project := range projects {
projectMaps[project.IssueID] = project.Project
projectMaps[project.ID] = project.Project
}
left -= limit
issueIDs = issueIDs[limit:]
}

for _, issue := range issues {
issue.Project = projectMaps[issue.ID]
projectIDs := issue.projectIDs(ctx)
for _, i := range projectIDs {
if projectMaps[i] != nil {
issue.Projects = append(issue.Projects, projectMaps[i])
}
}
}
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 @@ -66,10 +66,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)
}
}
}
123 changes: 67 additions & 56 deletions models/issues/issue_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,21 @@ import (

// 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").
if len(issue.Projects) == 0 {
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)
}
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 {
var ids []int64
if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id=?", issue.ID).Select("project_id").Find(&ids); err != nil {
return nil
}
return ip.ProjectID

return ids
}

// ProjectColumnID return project column id if issue was assigned to one
Expand Down Expand Up @@ -68,7 +62,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 +72,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 @@ -96,71 +90,88 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is

// 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 {
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64, newColumnID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
oldProjectID := issue.projectID(ctx)
oldProjectIDs := issue.projectIDs(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)
if err != nil {
projectDB := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID)
newProjectIDs, oldProjectIDs := util.DiffSlice(oldProjectIDs, newProjectIDs)

if len(oldProjectIDs) > 0 {
if _, err := projectDB.Where("issue_id=?", issue.ID).In("project_id", oldProjectIDs).Delete(&project_model.ProjectIssue{}); 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)
if err != nil {
for _, pID := range oldProjectIDs {
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldProjectID: pID,
ProjectID: 0,
}); err != nil {
return err
}
newColumnID = newDefaultColumn.ID
}
}

if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
if len(newProjectIDs) == 0 {
return nil
}

res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := projectDB.Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
In("project_id", newProjectIDs).
And("project_board_id=?", newColumnID).
Get(&res); err != nil {
return err
}
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)

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

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

pi = append(pi, &project_model.ProjectIssue{
IssueID: issue.ID,
ProjectID: pID,
ProjectColumnID: newColumnID,
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: pID,
}); 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
})
}
12 changes: 7 additions & 5 deletions models/issues/issue_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"

"xorm.io/builder"
"xorm.io/xorm"
Expand All @@ -36,7 +37,7 @@ type IssuesOptions struct { //nolint
ReviewedID int64
SubscriberID int64
MilestoneIDs []int64
ProjectID int64
ProjectIDs []int64
ProjectColumnID int64
IsClosed optional.Option[bool]
IsPull optional.Option[bool]
Expand Down Expand Up @@ -196,11 +197,12 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
}

func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
if opts.ProjectID > 0 { // specific project
opts.ProjectIDs = util.RemoveValue(opts.ProjectIDs, 0)
if len(opts.ProjectIDs) == 1 && opts.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(opts.ProjectIDs) > 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})))
In("project_issue.project_id", opts.ProjectIDs)
}
// opts.ProjectID == 0 means all projects,
// do not need to apply any condition
Expand Down
6 changes: 3 additions & 3 deletions models/issues/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,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.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)
}
}
}
Expand Down
9 changes: 7 additions & 2 deletions modules/indexer/issues/bleve/bleve.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,14 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
}

if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
if len(options.ProjectIDs) > 0 {
var projectQueries []query.Query
for _, projectID := range options.ProjectIDs {
projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_id"))
}
queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...))
}

if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
}
Expand Down
5 changes: 4 additions & 1 deletion modules/indexer/issues/db/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID),
ProjectColumnID: convertID(options.ProjectColumnID),
IsClosed: options.IsClosed,
IsPull: options.IsPull,
Expand All @@ -88,6 +87,10 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
opts.MilestoneIDs = options.MilestoneIDs
}

if len(options.ProjectIDs) > 0 {
opts.ProjectIDs = options.ProjectIDs
}

if options.NoLabelOnly {
opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID
} else {
Expand Down
6 changes: 2 additions & 4 deletions modules/indexer/issues/dboptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.MilestoneIDs = opts.MilestoneIDs
}

if opts.ProjectID > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID)
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
if len(opts.ProjectIDs) > 0 {
searchOpt.ProjectIDs = opts.ProjectIDs
}

searchOpt.AssigneeID = opts.AssigneeID
Expand Down
4 changes: 2 additions & 2 deletions modules/indexer/issues/elasticsearch/elasticsearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
}

if options.ProjectID.Has() {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
if len(options.ProjectIDs) > 0 {
query.Must(elastic.NewTermsQuery("project_id", toAnySlice(options.ProjectIDs)...))
}
if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
Expand Down
2 changes: 1 addition & 1 deletion modules/indexer/issues/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ func searchIssueInProject(t *testing.T) {
}{
{
SearchOptions{
ProjectID: optional.Some(int64(1)),
ProjectIDs: optional.Some(int64(1)),
},
[]int64{5, 3, 2, 1},
},
Expand Down
4 changes: 2 additions & 2 deletions modules/indexer/issues/internal/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type IndexerData struct {
LabelIDs []int64 `json:"label_ids"`
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"`
ProjectID int64 `json:"project_id"`
ProjectIDs []int64 `json:"project_id"`
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
PosterID int64 `json:"poster_id"`
AssigneeID int64 `json:"assignee_id"`
Expand Down Expand Up @@ -94,7 +94,7 @@ type SearchOptions struct {

MilestoneIDs []int64 // milestones the issues have

ProjectID optional.Option[int64] // project the issues belong to
ProjectIDs []int64 // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to

PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
Expand Down
Loading