mirror of
https://github.com/go-gitea/gitea.git
synced 2025-07-17 17:32:56 +02:00
Merge 0b78ae4161257ce7ab352d70adc4b5ee04930e19 into 32152a0ac03e912567a9d9a243729a9ed6288255
This commit is contained in:
commit
b40431c1ba
@ -84,7 +84,7 @@ type Issue struct {
|
||||
MilestoneID int64 `xorm:"INDEX"`
|
||||
Milestone *Milestone `xorm:"-"`
|
||||
isMilestoneLoaded bool `xorm:"-"`
|
||||
Project *project_model.Project `xorm:"-"`
|
||||
Projects []*project_model.Project `xorm:"-"`
|
||||
Priority int
|
||||
AssigneeID int64 `xorm:"-"`
|
||||
Assignee *user_model.User `xorm:"-"`
|
||||
@ -327,7 +327,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
|
||||
}
|
||||
|
||||
|
@ -207,14 +207,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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,28 +13,22 @@ 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").
|
||||
func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
|
||||
if issue.Projects == nil {
|
||||
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
|
||||
@ -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 {
|
||||
@ -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 {
|
||||
@ -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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
newColumnID = newDefaultColumn.ID
|
||||
}
|
||||
}
|
||||
projectDB := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID)
|
||||
newProjectIDs, oldProjectIDs := util.DiffSlice(oldProjectIDs, newProjectIDs)
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); 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
|
||||
}
|
||||
|
||||
if oldProjectID > 0 || newProjectID > 0 {
|
||||
for _, pID := range oldProjectIDs {
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: oldProjectID,
|
||||
ProjectID: newProjectID,
|
||||
OldProjectID: pID,
|
||||
ProjectID: 0,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newProjectID == 0 {
|
||||
return nil
|
||||
}
|
||||
if newColumnID == 0 {
|
||||
panic("newColumnID must not be zero") // shouldn't happen
|
||||
|
||||
if len(newProjectIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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).
|
||||
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)
|
||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
||||
|
||||
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: newProjectID,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
@ -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"
|
||||
@ -36,7 +37,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
||||
ReviewedID int64
|
||||
SubscriberID int64
|
||||
MilestoneIDs []int64
|
||||
ProjectID int64
|
||||
ProjectIDs []int64
|
||||
ProjectColumnID int64
|
||||
IsClosed 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) {
|
||||
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
|
||||
|
@ -418,10 +418,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"))
|
||||
}
|
||||
|
@ -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,
|
||||
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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()))
|
||||
|
@ -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},
|
||||
},
|
||||
|
@ -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"`
|
||||
@ -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
|
||||
|
@ -301,20 +301,25 @@ var cases = []*testIndexerCase{
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ProjectID",
|
||||
Name: "ProjectIDs",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
},
|
||||
ProjectID: optional.Some(int64(1)),
|
||||
ProjectIDs: []int64{1},
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
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 {
|
||||
return v.ProjectID == 1
|
||||
if len(data[v.ID].ProjectIDs) > 0 {
|
||||
return v.ProjectIDs[0] == 1
|
||||
}
|
||||
return false
|
||||
}), result.Total)
|
||||
},
|
||||
},
|
||||
@ -324,15 +329,20 @@ var cases = []*testIndexerCase{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
},
|
||||
ProjectID: optional.Some(int64(0)),
|
||||
ProjectIDs: []int64{0},
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
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 {
|
||||
return v.ProjectID == 0
|
||||
if len(data[v.ID].ProjectIDs) > 0 {
|
||||
return v.ProjectIDs[0] == 1
|
||||
}
|
||||
return false
|
||||
}), result.Total)
|
||||
},
|
||||
},
|
||||
@ -719,7 +729,7 @@ func generateDefaultIndexerData() []*internal.IndexerData {
|
||||
LabelIDs: labelIDs,
|
||||
NoLabel: len(labelIDs) == 0,
|
||||
MilestoneID: issueIndex % 4,
|
||||
ProjectID: issueIndex % 5,
|
||||
ProjectIDs: []int64{issueIndex % 5},
|
||||
ProjectColumnID: issueIndex % 6,
|
||||
PosterID: id%10 + 1, // PosterID should not be 0
|
||||
AssigneeID: issueIndex % 10,
|
||||
|
@ -180,8 +180,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
|
||||
}
|
||||
|
||||
if options.ProjectID.Has() {
|
||||
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
|
||||
if len(options.ProjectIDs) > 0 {
|
||||
query.And(inner_meilisearch.NewFilterIn("project_id", options.ProjectIDs...))
|
||||
}
|
||||
if options.ProjectColumnID.Has() {
|
||||
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
|
||||
|
@ -87,9 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var projectID int64
|
||||
if issue.Project != nil {
|
||||
projectID = issue.Project.ID
|
||||
projectIDs := make([]int64, 0, len(issue.Projects))
|
||||
for _, project := range issue.Projects {
|
||||
projectIDs = append(projectIDs, project.ID)
|
||||
}
|
||||
|
||||
projectColumnID, err := issue.ProjectColumnID(ctx)
|
||||
@ -110,7 +110,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
||||
LabelIDs: labels,
|
||||
NoLabel: len(labels) == 0,
|
||||
MilestoneID: issue.MilestoneID,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectIDs,
|
||||
ProjectColumnID: projectColumnID,
|
||||
PosterID: issue.PosterID,
|
||||
AssigneeID: issue.AssigneeID,
|
||||
|
@ -253,3 +253,49 @@ func ReserveLineBreakForTextarea(input string) string {
|
||||
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||
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()
|
||||
}
|
||||
|
@ -721,7 +721,7 @@ func CreateIssue(ctx *context.APIContext) {
|
||||
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) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
|
@ -189,7 +189,7 @@ func SearchIssues(ctx *context.Context) {
|
||||
IsClosed: isClosed,
|
||||
IncludedAnyLabelIDs: includedAnyLabels,
|
||||
MilestoneIDs: includedMilestones,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectID,
|
||||
SortBy: issue_indexer.SortByCreatedDesc,
|
||||
}
|
||||
|
||||
@ -349,7 +349,7 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
|
||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||
IsPull: isPull,
|
||||
IsClosed: isClosed,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectID,
|
||||
SortBy: issue_indexer.SortByCreatedDesc,
|
||||
}
|
||||
if since != 0 {
|
||||
@ -542,7 +542,6 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
RepoIDs: []int64{repo.ID},
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectID: projectID,
|
||||
AssigneeID: assigneeID,
|
||||
MentionedID: mentionedID,
|
||||
PosterID: posterUserID,
|
||||
@ -551,6 +550,11 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
IsPull: isPullOption,
|
||||
IssueIDs: nil,
|
||||
}
|
||||
|
||||
if projectID != 0 {
|
||||
statsOpts.ProjectIDs = []int64{projectID}
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
||||
if err != nil {
|
||||
@ -629,7 +633,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
ReviewRequestedID: reviewRequestedID,
|
||||
ReviewedID: reviewedID,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: []int64{projectID},
|
||||
IsClosed: isShowClosed,
|
||||
IsPull: isPullOption,
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
|
@ -121,8 +121,8 @@ func NewIssue(ctx *context.Context) {
|
||||
}
|
||||
|
||||
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
||||
pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project")
|
||||
if pageMetaData.ProjectsData.SelectedProjectID > 0 {
|
||||
pageMetaData.ProjectsData.SelectedProjectID = ctx.FormString("project")
|
||||
if len(pageMetaData.ProjectsData.SelectedProjectID) > 0 {
|
||||
if len(ctx.Req.URL.Query().Get("project")) > 0 {
|
||||
ctx.Data["redirect_after_creation"] = "project"
|
||||
}
|
||||
@ -240,7 +240,8 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item
|
||||
// ValidateRepoMetasForNewIssue check and returns repository's meta information
|
||||
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||
LabelIDs, AssigneeIDs []int64
|
||||
MilestoneID, ProjectID int64
|
||||
MilestoneID int64
|
||||
ProjectIDs []int64
|
||||
|
||||
Reviewers []*user_model.User
|
||||
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...)
|
||||
candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
|
||||
if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) {
|
||||
ctx.NotFound(nil)
|
||||
return ret
|
||||
inputProjectIDs, _ := base.StringsToInt64s(strings.Split(form.ProjectIDs, ","))
|
||||
pageMetaData.ProjectsData.SelectedProjectID = util.JoinSlice(inputProjectIDs, func(v int64) string {
|
||||
if candidateProjects.Contains(v) {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID
|
||||
return ""
|
||||
})
|
||||
|
||||
// prepare assignees
|
||||
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
|
||||
return ret
|
||||
}
|
||||
@ -343,9 +346,9 @@ func NewIssuePost(ctx *context.Context) {
|
||||
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) {
|
||||
// User must also be able to see the project.
|
||||
ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
|
||||
@ -385,7 +388,7 @@ func NewIssuePost(ctx *context.Context) {
|
||||
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) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
|
||||
} 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)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
|
||||
project, err := project_model.GetProjectByID(ctx, projectID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||
project, err := project_model.GetProjectByID(ctx, projectIDs[0])
|
||||
if err == nil {
|
||||
if project.Type == project_model.TypeOrganization {
|
||||
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
|
||||
|
@ -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"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
issue_service "code.gitea.io/gitea/services/issue"
|
||||
@ -34,7 +35,7 @@ type issueSidebarAssigneesData struct {
|
||||
}
|
||||
|
||||
type issueSidebarProjectsData struct {
|
||||
SelectedProjectID int64
|
||||
SelectedProjectID string
|
||||
OpenProjects []*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) {
|
||||
if d.Issue != nil && d.Issue.Project != nil {
|
||||
d.ProjectsData.SelectedProjectID = d.Issue.Project.ID
|
||||
if d.Issue != nil && len(d.Issue.Projects) > 0 {
|
||||
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)
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import (
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
@ -441,12 +442,9 @@ func UpdateIssueProject(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
projectID := ctx.FormInt64("id")
|
||||
projectIDs, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ","))
|
||||
for _, issue := range issues {
|
||||
if issue.Project != nil && issue.Project.ID == projectID {
|
||||
continue
|
||||
}
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs, 0); err != nil {
|
||||
if errors.Is(err, util.ErrPermissionDenied) {
|
||||
continue
|
||||
}
|
||||
|
@ -1303,7 +1303,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||
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 {
|
||||
attachments = form.Files
|
||||
@ -1409,8 +1409,8 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil {
|
||||
if ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectIDs, 0); err != nil {
|
||||
if !errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
||||
return
|
||||
|
@ -425,7 +425,7 @@ type CreateIssueForm struct {
|
||||
ReviewerIDs string `form:"reviewer_ids"`
|
||||
Ref string `form:"ref"`
|
||||
MilestoneID int64
|
||||
ProjectID int64
|
||||
ProjectIDs string `form:"project_ids"`
|
||||
Content string
|
||||
Files []string
|
||||
AllowMaintainerEdit bool
|
||||
|
@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
@ -41,12 +41,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
|
||||
return err
|
||||
}
|
||||
}
|
||||
if projectID > 0 {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs, 0)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -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 {
|
||||
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
|
||||
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) {
|
||||
o.ProjectID = project.ID
|
||||
o.ProjectIDs = []int64{project.ID}
|
||||
o.SortType = "project-column-sorting"
|
||||
}))
|
||||
if err != nil {
|
||||
@ -180,7 +185,7 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj
|
||||
|
||||
// for user or org projects, we need to check access permissions
|
||||
opts := issues_model.IssuesOptions{
|
||||
ProjectID: project.ID,
|
||||
ProjectIDs: []int64{project.ID},
|
||||
Doer: doer,
|
||||
AllPublic: doer == nil,
|
||||
Owner: project.Owner,
|
||||
|
@ -117,12 +117,12 @@ func Test_Projects(t *testing.T) {
|
||||
|
||||
// issue 6 belongs to private repo 3 under org 3
|
||||
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)
|
||||
|
||||
// issue 16 belongs to public repo 16 under org 3
|
||||
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)
|
||||
|
||||
projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{
|
||||
|
@ -1,11 +1,11 @@
|
||||
{{$pageMeta := .}}
|
||||
{{$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="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}}
|
||||
>
|
||||
<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}}">
|
||||
<a class="fixed-text muted">
|
||||
<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>
|
||||
{{range $data.OpenProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}} {{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
@ -33,6 +34,7 @@
|
||||
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div>
|
||||
{{range $data.ClosedProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}} {{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
@ -42,9 +44,9 @@
|
||||
</div>
|
||||
<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>
|
||||
{{if $issueProject}}
|
||||
<a class="item" href="{{$issueProject.Link ctx}}">
|
||||
{{svg $issueProject.IconName 18}} {{$issueProject.Title}}
|
||||
{{range $issueProject}}
|
||||
<a class="item" href="{{.Link ctx}}">
|
||||
{{svg .IconName 18}} {{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
@ -76,10 +76,10 @@
|
||||
<span class="gt-ellipsis">{{.Milestone.Name}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Project}}
|
||||
<a class="project flex-text-inline tw-max-w-[300px]" href="{{.Project.Link ctx}}">
|
||||
{{svg .Project.IconName 14}}
|
||||
<span class="gt-ellipsis">{{.Project.Title}}</span>
|
||||
{{range .Projects}}
|
||||
<a class="project flex-text-inline tw-max-w-[300px]" href="{{.Link ctx}}">
|
||||
{{svg .IconName 14}}
|
||||
<span class="gt-ellipsis">{{.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}}
|
||||
|
Loading…
x
Reference in New Issue
Block a user