0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-17 21:52:53 +02:00

Merge 0b78ae4161257ce7ab352d70adc4b5ee04930e19 into 32152a0ac03e912567a9d9a243729a9ed6288255

This commit is contained in:
Tyrone Yeh 2025-07-10 20:00:16 +02:00 committed by GitHub
commit b40431c1ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 262 additions and 172 deletions

View File

@ -74,17 +74,17 @@ type Issue struct {
PosterID int64 `xorm:"INDEX"` PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"` Poster *user_model.User `xorm:"-"`
OriginalAuthor string OriginalAuthor string
OriginalAuthorID int64 `xorm:"index"` OriginalAuthorID int64 `xorm:"index"`
Title string `xorm:"name"` Title string `xorm:"name"`
Content string `xorm:"LONGTEXT"` Content string `xorm:"LONGTEXT"`
RenderedContent template.HTML `xorm:"-"` RenderedContent template.HTML `xorm:"-"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"` ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
Labels []*Label `xorm:"-"` Labels []*Label `xorm:"-"`
isLabelsLoaded bool `xorm:"-"` isLabelsLoaded bool `xorm:"-"`
MilestoneID int64 `xorm:"INDEX"` MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"` Milestone *Milestone `xorm:"-"`
isMilestoneLoaded bool `xorm:"-"` isMilestoneLoaded bool `xorm:"-"`
Project *project_model.Project `xorm:"-"` Projects []*project_model.Project `xorm:"-"`
Priority int Priority int
AssigneeID int64 `xorm:"-"` AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"` Assignee *user_model.User `xorm:"-"`
@ -327,7 +327,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
return err return err
} }
if err = issue.LoadProject(ctx); err != nil { if err = issue.LoadProjects(ctx); err != nil {
return err return err
} }

View File

@ -207,14 +207,19 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
return err return err
} }
for _, project := range projects { for _, project := range projects {
projectMaps[project.IssueID] = project.Project projectMaps[project.ID] = project.Project
} }
left -= limit left -= limit
issueIDs = issueIDs[limit:] issueIDs = issueIDs[limit:]
} }
for _, issue := range issues { 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 return nil
} }

View File

@ -66,10 +66,10 @@ func TestIssueList_LoadAttributes(t *testing.T) {
} }
if issue.ID == int64(1) { if issue.ID == int64(1) {
assert.Equal(t, int64(400), issue.TotalTrackedTime) assert.Equal(t, int64(400), issue.TotalTrackedTime)
assert.NotNil(t, issue.Project) assert.NotNil(t, issue.Projects[0])
assert.Equal(t, int64(1), issue.Project.ID) assert.Equal(t, int64(1), issue.Projects[0].ID)
} else { } else {
assert.Nil(t, issue.Project) assert.Nil(t, issue.Projects)
} }
} }
} }

View File

@ -13,28 +13,22 @@ import (
) )
// LoadProject load the project the issue was assigned to // LoadProject load the project the issue was assigned to
func (issue *Issue) LoadProject(ctx context.Context) (err error) { func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
if issue.Project == nil { if issue.Projects == nil {
var p project_model.Project err = db.GetEngine(ctx).Table("project").
has, err := db.GetEngine(ctx).Table("project").
Join("INNER", "project_issue", "project.id=project_issue.project_id"). Join("INNER", "project_issue", "project.id=project_issue.project_id").
Where("project_issue.issue_id = ?", issue.ID).Get(&p) Where("project_issue.issue_id = ?", issue.ID).Find(&issue.Projects)
if err != nil {
return err
} else if has {
issue.Project = &p
}
} }
return err return err
} }
func (issue *Issue) projectID(ctx context.Context) int64 { func (issue *Issue) projectIDs(ctx context.Context) []int64 {
var ip project_model.ProjectIssue var ids []int64
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id=?", issue.ID).Select("project_id").Find(&ids); err != nil {
if err != nil || !has { return nil
return 0
} }
return ip.ProjectID
return ids
} }
// ProjectColumnID return project column id if issue was assigned to one // ProjectColumnID return project column id if issue was assigned to one
@ -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) { func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) {
issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
o.ProjectColumnID = b.ID o.ProjectColumnID = b.ID
o.ProjectID = b.ProjectID o.ProjectIDs = []int64{b.ProjectID}
o.SortType = "project-column-sorting" o.SortType = "project-column-sorting"
})) }))
if err != nil { if err != nil {
@ -78,7 +72,7 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
if b.Default { if b.Default {
issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
o.ProjectColumnID = db.NoConditionID o.ProjectColumnID = db.NoConditionID
o.ProjectID = b.ProjectID o.ProjectIDs = []int64{b.ProjectID}
o.SortType = "project-column-sorting" o.SortType = "project-column-sorting"
})) }))
if err != nil { if err != nil {
@ -96,71 +90,88 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
// IssueAssignOrRemoveProject changes the project associated with an issue // IssueAssignOrRemoveProject changes the project associated with an issue
// If newProjectID is 0, the issue is removed from the project // 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 { return db.WithTx(ctx, func(ctx context.Context) error {
oldProjectID := issue.projectID(ctx) oldProjectIDs := issue.projectIDs(ctx)
if err := issue.LoadRepo(ctx); err != nil { if err := issue.LoadRepo(ctx); err != nil {
return err return err
} }
// Only check if we add a new project and not remove it. projectDB := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID)
if newProjectID > 0 { newProjectIDs, oldProjectIDs := util.DiffSlice(oldProjectIDs, newProjectIDs)
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
if err != nil { if len(oldProjectIDs) > 0 {
if _, err := projectDB.Where("issue_id=?", issue.ID).In("project_id", oldProjectIDs).Delete(&project_model.ProjectIssue{}); err != nil {
return err return err
} }
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) { for _, pID := range oldProjectIDs {
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID) if _, err := CreateComment(ctx, &CreateCommentOptions{
} Type: CommentTypeProject,
if newColumnID == 0 { Doer: doer,
newDefaultColumn, err := newProject.MustDefaultColumn(ctx) Repo: issue.Repo,
if err != nil { Issue: issue,
OldProjectID: pID,
ProjectID: 0,
}); err != nil {
return err 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 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 return nil
} }
if newColumnID == 0 {
panic("newColumnID must not be zero") // shouldn't happen
}
res := struct { res := struct {
MaxSorting int64 MaxSorting int64
IssueCount int64 IssueCount int64
}{} }{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). if _, err := projectDB.Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
Where("project_id=?", newProjectID). In("project_id", newProjectIDs).
And("project_board_id=?", newColumnID). And("project_board_id=?", newColumnID).
Get(&res); err != nil { Get(&res); err != nil {
return err return err
} }
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.Insert(ctx, &project_model.ProjectIssue{
IssueID: issue.ID, pi := make([]*project_model.ProjectIssue, 0, len(newProjectIDs))
ProjectID: newProjectID,
ProjectColumnID: newColumnID, for _, pID := range newProjectIDs {
Sorting: newSorting, 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 _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
OldProjectID: 0,
ProjectID: pID,
}); err != nil {
return err
}
}
if len(pi) > 0 {
return db.Insert(ctx, pi)
}
return nil
}) })
} }

View File

@ -16,6 +16,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm" "xorm.io/xorm"
@ -36,7 +37,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
ReviewedID int64 ReviewedID int64
SubscriberID int64 SubscriberID int64
MilestoneIDs []int64 MilestoneIDs []int64
ProjectID int64 ProjectIDs []int64
ProjectColumnID int64 ProjectColumnID int64
IsClosed optional.Option[bool] IsClosed optional.Option[bool]
IsPull optional.Option[bool] IsPull optional.Option[bool]
@ -198,11 +199,12 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
} }
func applyProjectCondition(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"). sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
And("project_issue.project_id=?", opts.ProjectID) In("project_issue.project_id", opts.ProjectIDs)
} 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})))
} }
// opts.ProjectID == 0 means all projects, // opts.ProjectID == 0 means all projects,
// do not need to apply any condition // do not need to apply any condition

View File

@ -418,10 +418,10 @@ func TestIssueLoadAttributes(t *testing.T) {
} }
if issue.ID == int64(1) { if issue.ID == int64(1) {
assert.Equal(t, int64(400), issue.TotalTrackedTime) assert.Equal(t, int64(400), issue.TotalTrackedTime)
assert.NotNil(t, issue.Project) assert.NotNil(t, issue.Projects[0])
assert.Equal(t, int64(1), issue.Project.ID) assert.Equal(t, int64(1), issue.Projects[0].ID)
} else { } else {
assert.Nil(t, issue.Project) assert.Nil(t, issue.Projects)
} }
} }
} }

View File

@ -241,9 +241,14 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
} }
if options.ProjectID.Has() { if len(options.ProjectIDs) > 0 {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) 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() { if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
} }

View File

@ -65,7 +65,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewRequestedID: convertID(options.ReviewRequestedID), ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID), ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID), SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID),
ProjectColumnID: convertID(options.ProjectColumnID), ProjectColumnID: convertID(options.ProjectColumnID),
IsClosed: options.IsClosed, IsClosed: options.IsClosed,
IsPull: options.IsPull, IsPull: options.IsPull,
@ -88,6 +87,10 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
opts.MilestoneIDs = options.MilestoneIDs opts.MilestoneIDs = options.MilestoneIDs
} }
if len(options.ProjectIDs) > 0 {
opts.ProjectIDs = options.ProjectIDs
}
if options.NoLabelOnly { if options.NoLabelOnly {
opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID
} else { } else {

View File

@ -46,10 +46,8 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.MilestoneIDs = opts.MilestoneIDs searchOpt.MilestoneIDs = opts.MilestoneIDs
} }
if opts.ProjectID > 0 { if len(opts.ProjectIDs) > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID) searchOpt.ProjectIDs = opts.ProjectIDs
} 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)
} }
searchOpt.AssigneeID = opts.AssigneeID searchOpt.AssigneeID = opts.AssigneeID

View File

@ -204,8 +204,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
} }
if options.ProjectID.Has() { if len(options.ProjectIDs) > 0 {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) query.Must(elastic.NewTermsQuery("project_id", toAnySlice(options.ProjectIDs)...))
} }
if options.ProjectColumnID.Has() { if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))

View File

@ -416,7 +416,7 @@ func searchIssueInProject(t *testing.T) {
}{ }{
{ {
SearchOptions{ SearchOptions{
ProjectID: optional.Some(int64(1)), ProjectIDs: optional.Some(int64(1)),
}, },
[]int64{5, 3, 2, 1}, []int64{5, 3, 2, 1},
}, },

View File

@ -30,7 +30,7 @@ type IndexerData struct {
LabelIDs []int64 `json:"label_ids"` LabelIDs []int64 `json:"label_ids"`
NoLabel bool `json:"no_label"` // True if LabelIDs is empty NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"` 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 ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
PosterID int64 `json:"poster_id"` PosterID int64 `json:"poster_id"`
AssigneeID int64 `json:"assignee_id"` AssigneeID int64 `json:"assignee_id"`
@ -94,7 +94,7 @@ type SearchOptions struct {
MilestoneIDs []int64 // milestones the issues have 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 ProjectColumnID optional.Option[int64] // project column the issues belong to
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID PosterID string // poster of the issues, "(none)" or "(any)" or a user ID

View File

@ -301,20 +301,25 @@ var cases = []*testIndexerCase{
}, },
}, },
{ {
Name: "ProjectID", Name: "ProjectIDs",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
ProjectID: optional.Some(int64(1)), ProjectIDs: []int64{1},
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5) assert.Len(t, result.Hits, 5)
for _, v := range result.Hits { for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectID) if len(data[v.ID].ProjectIDs) > 0 {
assert.Equal(t, int64(1), data[v.ID].ProjectIDs[0])
}
} }
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 1 if len(data[v.ID].ProjectIDs) > 0 {
return v.ProjectIDs[0] == 1
}
return false
}), result.Total) }), result.Total)
}, },
}, },
@ -324,15 +329,20 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
PageSize: 5, PageSize: 5,
}, },
ProjectID: optional.Some(int64(0)), ProjectIDs: []int64{0},
}, },
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5) assert.Len(t, result.Hits, 5)
for _, v := range result.Hits { for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectID) if len(data[v.ID].ProjectIDs) > 0 {
assert.Equal(t, int64(0), data[v.ID].ProjectIDs[0])
}
} }
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 0 if len(data[v.ID].ProjectIDs) > 0 {
return v.ProjectIDs[0] == 1
}
return false
}), result.Total) }), result.Total)
}, },
}, },
@ -719,7 +729,7 @@ func generateDefaultIndexerData() []*internal.IndexerData {
LabelIDs: labelIDs, LabelIDs: labelIDs,
NoLabel: len(labelIDs) == 0, NoLabel: len(labelIDs) == 0,
MilestoneID: issueIndex % 4, MilestoneID: issueIndex % 4,
ProjectID: issueIndex % 5, ProjectIDs: []int64{issueIndex % 5},
ProjectColumnID: issueIndex % 6, ProjectColumnID: issueIndex % 6,
PosterID: id%10 + 1, // PosterID should not be 0 PosterID: id%10 + 1, // PosterID should not be 0
AssigneeID: issueIndex % 10, AssigneeID: issueIndex % 10,

View File

@ -180,8 +180,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
} }
if options.ProjectID.Has() { if len(options.ProjectIDs) > 0 {
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) query.And(inner_meilisearch.NewFilterIn("project_id", options.ProjectIDs...))
} }
if options.ProjectColumnID.Has() { if options.ProjectColumnID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))

View File

@ -87,9 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
return nil, false, err return nil, false, err
} }
var projectID int64 projectIDs := make([]int64, 0, len(issue.Projects))
if issue.Project != nil { for _, project := range issue.Projects {
projectID = issue.Project.ID projectIDs = append(projectIDs, project.ID)
} }
projectColumnID, err := issue.ProjectColumnID(ctx) projectColumnID, err := issue.ProjectColumnID(ctx)
@ -110,7 +110,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
LabelIDs: labels, LabelIDs: labels,
NoLabel: len(labels) == 0, NoLabel: len(labels) == 0,
MilestoneID: issue.MilestoneID, MilestoneID: issue.MilestoneID,
ProjectID: projectID, ProjectIDs: projectIDs,
ProjectColumnID: projectColumnID, ProjectColumnID: projectColumnID,
PosterID: issue.PosterID, PosterID: issue.PosterID,
AssigneeID: issue.AssigneeID, AssigneeID: issue.AssigneeID,

View File

@ -253,3 +253,49 @@ func ReserveLineBreakForTextarea(input string) string {
// Other than this, we should respect the original content, even leading or trailing spaces. // Other than this, we should respect the original content, even leading or trailing spaces.
return strings.ReplaceAll(input, "\r\n", "\n") return strings.ReplaceAll(input, "\r\n", "\n")
} }
func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
oldSet := make(map[T]struct{}, len(oldSlice))
newSet := make(map[T]struct{}, len(newSlice))
for _, v := range oldSlice {
oldSet[v] = struct{}{}
}
for _, v := range newSlice {
newSet[v] = struct{}{}
}
for v := range newSet {
if _, found := oldSet[v]; !found {
added = append(added, v)
}
}
for v := range oldSet {
if _, found := newSet[v]; !found {
removed = append(removed, v)
}
}
return added, removed
}
func RemoveValue[T comparable](a []T, target T) []T {
n := 0
for _, v := range a {
if v != target {
a[n] = v
n++
}
}
return a[:n]
}
func JoinSlice[T any](items []T, toString func(T) string) string {
var b strings.Builder
sep := ""
for _, item := range items {
b.WriteString(sep)
b.WriteString(toString(item))
sep = ","
}
return b.String()
}

View File

@ -721,7 +721,7 @@ func CreateIssue(ctx *context.APIContext) {
form.Labels = make([]int64, 0) form.Labels = make([]int64, 0)
} }
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, nil); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.APIError(http.StatusBadRequest, err) ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, user_model.ErrBlockedUser) { } else if errors.Is(err, user_model.ErrBlockedUser) {

View File

@ -189,7 +189,7 @@ func SearchIssues(ctx *context.Context) {
IsClosed: isClosed, IsClosed: isClosed,
IncludedAnyLabelIDs: includedAnyLabels, IncludedAnyLabelIDs: includedAnyLabels,
MilestoneIDs: includedMilestones, MilestoneIDs: includedMilestones,
ProjectID: projectID, ProjectIDs: projectID,
SortBy: issue_indexer.SortByCreatedDesc, SortBy: issue_indexer.SortByCreatedDesc,
} }
@ -345,12 +345,12 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
Page: ctx.FormInt("page"), Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}, },
Keyword: keyword, Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull, IsPull: isPull,
IsClosed: isClosed, IsClosed: isClosed,
ProjectID: projectID, ProjectIDs: projectID,
SortBy: issue_indexer.SortByCreatedDesc, SortBy: issue_indexer.SortByCreatedDesc,
} }
if since != 0 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) searchOpt.UpdatedAfterUnix = optional.Some(since)
@ -542,7 +542,6 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
RepoIDs: []int64{repo.ID}, RepoIDs: []int64{repo.ID},
LabelIDs: preparedLabelFilter.SelectedLabelIDs, LabelIDs: preparedLabelFilter.SelectedLabelIDs,
MilestoneIDs: mileIDs, MilestoneIDs: mileIDs,
ProjectID: projectID,
AssigneeID: assigneeID, AssigneeID: assigneeID,
MentionedID: mentionedID, MentionedID: mentionedID,
PosterID: posterUserID, PosterID: posterUserID,
@ -551,6 +550,11 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
IsPull: isPullOption, IsPull: isPullOption,
IssueIDs: nil, IssueIDs: nil,
} }
if projectID != 0 {
statsOpts.ProjectIDs = []int64{projectID}
}
if keyword != "" { if keyword != "" {
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts)) keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
if err != nil { if err != nil {
@ -629,7 +633,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
ReviewRequestedID: reviewRequestedID, ReviewRequestedID: reviewRequestedID,
ReviewedID: reviewedID, ReviewedID: reviewedID,
MilestoneIDs: mileIDs, MilestoneIDs: mileIDs,
ProjectID: projectID, ProjectIDs: []int64{projectID},
IsClosed: isShowClosed, IsClosed: isShowClosed,
IsPull: isPullOption, IsPull: isPullOption,
LabelIDs: preparedLabelFilter.SelectedLabelIDs, LabelIDs: preparedLabelFilter.SelectedLabelIDs,

View File

@ -121,8 +121,8 @@ func NewIssue(ctx *context.Context) {
} }
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project") pageMetaData.ProjectsData.SelectedProjectID = ctx.FormString("project")
if pageMetaData.ProjectsData.SelectedProjectID > 0 { if len(pageMetaData.ProjectsData.SelectedProjectID) > 0 {
if len(ctx.Req.URL.Query().Get("project")) > 0 { if len(ctx.Req.URL.Query().Get("project")) > 0 {
ctx.Data["redirect_after_creation"] = "project" ctx.Data["redirect_after_creation"] = "project"
} }
@ -239,8 +239,9 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item
// ValidateRepoMetasForNewIssue check and returns repository's meta information // ValidateRepoMetasForNewIssue check and returns repository's meta information
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
LabelIDs, AssigneeIDs []int64 LabelIDs, AssigneeIDs []int64
MilestoneID, ProjectID int64 MilestoneID int64
ProjectIDs []int64
Reviewers []*user_model.User Reviewers []*user_model.User
TeamReviewers []*organization.Team TeamReviewers []*organization.Team
@ -269,11 +270,13 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...) allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...)
candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) { inputProjectIDs, _ := base.StringsToInt64s(strings.Split(form.ProjectIDs, ","))
ctx.NotFound(nil) pageMetaData.ProjectsData.SelectedProjectID = util.JoinSlice(inputProjectIDs, func(v int64) string {
return ret if candidateProjects.Contains(v) {
} return strconv.FormatInt(v, 10)
pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID }
return ""
})
// prepare assignees // prepare assignees
candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
@ -318,7 +321,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
} }
} }
ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectIDs = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, inputProjectIDs
ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
return ret return ret
} }
@ -343,9 +346,9 @@ func NewIssuePost(ctx *context.Context) {
return return
} }
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs
if projectID > 0 { if len(projectIDs) > 0 {
if !ctx.Repo.CanRead(unit.TypeProjects) { if !ctx.Repo.CanRead(unit.TypeProjects) {
// User must also be able to see the project. // User must also be able to see the project.
ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects") ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
@ -385,7 +388,7 @@ func NewIssuePost(ctx *context.Context) {
Ref: form.Ref, Ref: form.Ref,
} }
if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
} else if errors.Is(err, user_model.ErrBlockedUser) { } else if errors.Is(err, user_model.ErrBlockedUser) {
@ -397,8 +400,8 @@ func NewIssuePost(ctx *context.Context) {
} }
log.Trace("Issue created: %d/%d", repo.ID, issue.ID) log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
project, err := project_model.GetProjectByID(ctx, projectID) project, err := project_model.GetProjectByID(ctx, projectIDs[0])
if err == nil { if err == nil {
if project.Type == project_model.TypeOrganization { if project.Type == project_model.TypeOrganization {
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID)) ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))

View File

@ -16,6 +16,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"
shared_user "code.gitea.io/gitea/routers/web/shared/user" shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
issue_service "code.gitea.io/gitea/services/issue" issue_service "code.gitea.io/gitea/services/issue"
@ -34,7 +35,7 @@ type issueSidebarAssigneesData struct {
} }
type issueSidebarProjectsData struct { type issueSidebarProjectsData struct {
SelectedProjectID int64 SelectedProjectID string
OpenProjects []*project_model.Project OpenProjects []*project_model.Project
ClosedProjects []*project_model.Project ClosedProjects []*project_model.Project
} }
@ -160,8 +161,10 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
} }
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
if d.Issue != nil && d.Issue.Project != nil { if d.Issue != nil && len(d.Issue.Projects) > 0 {
d.ProjectsData.SelectedProjectID = d.Issue.Project.ID d.ProjectsData.SelectedProjectID = util.JoinSlice(d.Issue.Projects, func(v *project_model.Project) string {
return strconv.FormatInt(v.ID, 10)
})
} }
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
} }

View File

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
@ -441,12 +442,9 @@ func UpdateIssueProject(ctx *context.Context) {
return return
} }
projectID := ctx.FormInt64("id") projectIDs, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ","))
for _, issue := range issues { for _, issue := range issues {
if issue.Project != nil && issue.Project.ID == projectID { if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs, 0); err != nil {
continue
}
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
if errors.Is(err, util.ErrPermissionDenied) { if errors.Is(err, util.ErrPermissionDenied) {
continue continue
} }

View File

@ -1303,7 +1303,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return return
} }
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs
if setting.Attachment.Enabled { if setting.Attachment.Enabled {
attachments = form.Files attachments = form.Files
@ -1409,8 +1409,8 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return return
} }
if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { if ctx.Repo.CanWrite(unit.TypeProjects) {
if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectIDs, 0); err != nil {
if !errors.Is(err, util.ErrPermissionDenied) { if !errors.Is(err, util.ErrPermissionDenied) {
ctx.ServerError("IssueAssignOrRemoveProject", err) ctx.ServerError("IssueAssignOrRemoveProject", err)
return return

View File

@ -425,7 +425,7 @@ type CreateIssueForm struct {
ReviewerIDs string `form:"reviewer_ids"` ReviewerIDs string `form:"reviewer_ids"`
Ref string `form:"ref"` Ref string `form:"ref"`
MilestoneID int64 MilestoneID int64
ProjectID int64 ProjectIDs string `form:"project_ids"`
Content string Content string
Files []string Files []string
AllowMaintainerEdit bool AllowMaintainerEdit bool

View File

@ -23,7 +23,7 @@ import (
) )
// NewIssue creates new issue with labels for repository. // NewIssue creates new issue with labels for repository.
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error { func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs, projectIDs []int64) error {
if err := issue.LoadPoster(ctx); err != nil { if err := issue.LoadPoster(ctx); err != nil {
return err return err
} }
@ -41,12 +41,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
return err return err
} }
} }
if projectID > 0 { return issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs, 0)
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
return err
}
}
return nil
}); err != nil { }); err != nil {
return err return err
} }

View File

@ -77,7 +77,12 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
} }
} }
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID) _, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
if err != nil { if err != nil {
return err return err
} }
@ -89,7 +94,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
// LoadIssuesFromProject load issues assigned to each project column inside the given project // LoadIssuesFromProject load issues assigned to each project column inside the given project
func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (map[int64]issues_model.IssueList, error) { func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (map[int64]issues_model.IssueList, error) {
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
o.ProjectID = project.ID o.ProjectIDs = []int64{project.ID}
o.SortType = "project-column-sorting" o.SortType = "project-column-sorting"
})) }))
if err != nil { if err != nil {
@ -180,10 +185,10 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj
// for user or org projects, we need to check access permissions // for user or org projects, we need to check access permissions
opts := issues_model.IssuesOptions{ opts := issues_model.IssuesOptions{
ProjectID: project.ID, ProjectIDs: []int64{project.ID},
Doer: doer, Doer: doer,
AllPublic: doer == nil, AllPublic: doer == nil,
Owner: project.Owner, Owner: project.Owner,
} }
var err error var err error

View File

@ -117,12 +117,12 @@ func Test_Projects(t *testing.T) {
// issue 6 belongs to private repo 3 under org 3 // issue 6 belongs to private repo 3 under org 3
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6}) issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue6, user2, project1.ID, column1.ID) err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue6, user2, []int64{project1.ID}, column1.ID)
assert.NoError(t, err) assert.NoError(t, err)
// issue 16 belongs to public repo 16 under org 3 // issue 16 belongs to public repo 16 under org 3
issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16}) issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16})
err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue16, user2, project1.ID, column1.ID) err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue16, user2, []int64{project1.ID}, column1.ID)
assert.NoError(t, err) assert.NoError(t, err)
projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{ projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{

View File

@ -1,11 +1,11 @@
{{$pageMeta := .}} {{$pageMeta := .}}
{{$data := .ProjectsData}} {{$data := .ProjectsData}}
{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}} {{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Projects}}{{$issueProject = $pageMeta.Issue.Projects}}{{end}}
<div class="divider"></div> <div class="divider"></div>
<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all" <div class="issue-sidebar-combo" data-selection-mode="multiple" data-update-algo="all"
{{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}} {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
> >
<input class="combo-value" name="project_id" type="hidden" value="{{$data.SelectedProjectID}}"> <input class="combo-value" name="project_ids" type="hidden" value="{{$data.SelectedProjectID}}">
<div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}"> <div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
<a class="fixed-text muted"> <a class="fixed-text muted">
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}} <strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
@ -24,6 +24,7 @@
<div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div> <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div>
{{range $data.OpenProjects}} {{range $data.OpenProjects}}
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
<span class="item-check-mark">{{svg "octicon-check"}}</span>
{{svg .IconName 18}} {{.Title}} {{svg .IconName 18}} {{.Title}}
</a> </a>
{{end}} {{end}}
@ -33,6 +34,7 @@
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div> <div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div>
{{range $data.ClosedProjects}} {{range $data.ClosedProjects}}
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
<span class="item-check-mark">{{svg "octicon-check"}}</span>
{{svg .IconName 18}} {{.Title}} {{svg .IconName 18}} {{.Title}}
</a> </a>
{{end}} {{end}}
@ -42,9 +44,9 @@
</div> </div>
<div class="ui list muted-links flex-items-block"> <div class="ui list muted-links flex-items-block">
<span class="item empty-list {{if $issueProject}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> <span class="item empty-list {{if $issueProject}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span>
{{if $issueProject}} {{range $issueProject}}
<a class="item" href="{{$issueProject.Link ctx}}"> <a class="item" href="{{.Link ctx}}">
{{svg $issueProject.IconName 18}} {{$issueProject.Title}} {{svg .IconName 18}} {{.Title}}
</a> </a>
{{end}} {{end}}
</div> </div>

View File

@ -76,10 +76,10 @@
<span class="gt-ellipsis">{{.Milestone.Name}}</span> <span class="gt-ellipsis">{{.Milestone.Name}}</span>
</a> </a>
{{end}} {{end}}
{{if .Project}} {{range .Projects}}
<a class="project flex-text-inline tw-max-w-[300px]" href="{{.Project.Link ctx}}"> <a class="project flex-text-inline tw-max-w-[300px]" href="{{.Link ctx}}">
{{svg .Project.IconName 14}} {{svg .IconName 14}}
<span class="gt-ellipsis">{{.Project.Title}}</span> <span class="gt-ellipsis">{{.Title}}</span>
</a> </a>
{{end}} {{end}}
{{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}} {{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}}