diff --git a/models/project/column.go b/models/project/column.go index 6f4452984e..d8147ecde1 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -48,7 +48,9 @@ type Column struct { ProjectID int64 `xorm:"INDEX NOT NULL"` CreatorID int64 `xorm:"NOT NULL"` - NumIssues int64 `xorm:"-"` + NumIssues int64 `xorm:"-"` + NumOpenIssues int64 `xorm:"-"` + NumClosedIssues int64 `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` diff --git a/models/project/column_list.go b/models/project/column_list.go index 2016db3357..dbd70bed81 100644 --- a/models/project/column_list.go +++ b/models/project/column_list.go @@ -5,8 +5,11 @@ package project import ( "context" + "strconv" "code.gitea.io/gitea/models/db" + + "xorm.io/builder" ) // CountProjectColumns returns the total number of columns for a project @@ -27,6 +30,58 @@ func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions return columns, nil } +// LoadIssueCounts populates NumIssues, NumOpenIssues, and NumClosedIssues on +// every column in the list using two grouped queries against project_issue +// joined with issue. Columns with no attached issues stay at zero counts — +// nothing else has to be wired up by the caller. +func (cl ColumnList) LoadIssueCounts(ctx context.Context) error { + if len(cl) == 0 { + return nil + } + + columnIDs := make([]int64, 0, len(cl)) + for _, c := range cl { + columnIDs = append(columnIDs, c.ID) + } + + openCounts, err := countColumnIssuesByState(ctx, columnIDs, false) + if err != nil { + return err + } + closedCounts, err := countColumnIssuesByState(ctx, columnIDs, true) + if err != nil { + return err + } + + for _, c := range cl { + c.NumOpenIssues = openCounts[c.ID] + c.NumClosedIssues = closedCounts[c.ID] + c.NumIssues = c.NumOpenIssues + c.NumClosedIssues + } + return nil +} + +func countColumnIssuesByState(ctx context.Context, columnIDs []int64, isClosed bool) (map[int64]int64, error) { + out := make(map[int64]int64, len(columnIDs)) + cond := builder.In("project_issue.project_board_id", columnIDs). + And(builder.Eq{"issue.is_closed": isClosed}) + sub := builder.Select("project_issue.project_board_id AS project_board_id", "COUNT(*) AS cnt"). + From("project_issue"). + InnerJoin("issue", "issue.id = project_issue.issue_id"). + Where(cond). + GroupBy("project_issue.project_board_id") + rows, err := db.GetEngine(ctx).Query(sub) + if err != nil { + return nil, err + } + for _, r := range rows { + columnID, _ := strconv.ParseInt(string(r["project_board_id"]), 10, 64) + cnt, _ := strconv.ParseInt(string(r["cnt"]), 10, 64) + out[columnID] = cnt + } + return out, nil +} + func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { columns := make([]*Column, 0, len(columnsIDs)) if len(columnsIDs) == 0 { diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go index adc134725e..fe373fc116 100644 --- a/models/project/column_list_test.go +++ b/models/project/column_list_test.go @@ -17,6 +17,7 @@ func TestProjectColumns(t *testing.T) { t.Run("CountProjectColumns", testCountProjectColumns) t.Run("GetProjectColumns", testGetProjectColumns) t.Run("GetColumnsByIDs", testGetColumnsByIDs) + t.Run("LoadIssueCountsEmpty", testLoadIssueCountsEmpty) } func testCountProjectColumns(t *testing.T) { @@ -51,6 +52,14 @@ func testGetProjectColumns(t *testing.T) { assert.Len(t, allIDs, 3) } +func testLoadIssueCountsEmpty(t *testing.T) { + // Empty input is a fast path — must not touch the database and must not error. + // (The full open/closed-count behavior is exercised by the integration tests + // in tests/integration/api_*_project_test.go, which can join against the issue + // table; the unit-test fixture set here intentionally excludes it.) + assert.NoError(t, ColumnList{}.LoadIssueCounts(t.Context())) +} + func testGetColumnsByIDs(t *testing.T) { project, err := GetProjectByID(t.Context(), 1) assert.NoError(t, err) diff --git a/modules/structs/project.go b/modules/structs/project.go index 372d58ef97..905f31a50a 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -68,10 +68,12 @@ type ProjectColumn struct { Title string `json:"title"` Default bool `json:"default"` Sorting int `json:"sorting"` - Color string `json:"color,omitempty"` - ProjectID int64 `json:"project_id"` - Creator *User `json:"creator,omitempty"` - NumIssues int64 `json:"num_issues,omitempty"` + Color string `json:"color,omitempty"` + ProjectID int64 `json:"project_id"` + Creator *User `json:"creator,omitempty"` + NumOpenIssues int64 `json:"num_open_issues"` + NumClosedIssues int64 `json:"num_closed_issues"` + NumIssues int64 `json:"num_issues"` // swagger:strfmt date-time CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 72a7597c69..b5e3fc2c3c 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -403,6 +403,11 @@ func ListOrgProjectColumns(ctx *context.APIContext) { return } + if err := columns.LoadIssueCounts(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) @@ -621,6 +626,10 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: state + // in: query + // description: filter issues by state. "open" (default), "closed", or "all". + // type: string // - name: page // in: query // description: page number of results to return (1-based) @@ -649,6 +658,7 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) { Paginator: &listOptions, ProjectIDs: []int64{column.ProjectID}, ProjectColumnID: column.ID, + IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")), SortType: issues_model.SortTypeProjectColumnSorting, } diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ec042f369e..e3650224bb 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -435,6 +435,11 @@ func ListProjectColumns(ctx *context.APIContext) { return } + if err := columns.LoadIssueCounts(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) @@ -673,6 +678,10 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: state + // in: query + // description: filter issues by state. "open" (default), "closed", or "all". + // type: string // - name: page // in: query // description: page number of results to return (1-based) @@ -698,6 +707,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { RepoIDs: []int64{ctx.Repo.Repository.ID}, ProjectIDs: []int64{column.ProjectID}, ProjectColumnID: column.ID, + IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")), SortType: issues_model.SortTypeProjectColumnSorting, } diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go index a47cc893bc..f14fc8a608 100644 --- a/routers/api/v1/user/project.go +++ b/routers/api/v1/user/project.go @@ -425,6 +425,11 @@ func ListUserProjectColumns(ctx *context.APIContext) { return } + if err := columns.LoadIssueCounts(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) @@ -655,6 +660,10 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: state + // in: query + // description: filter issues by state. "open" (default), "closed", or "all". + // type: string // - name: page // in: query // description: page number of results to return (1-based) @@ -681,6 +690,7 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) { Paginator: &listOptions, ProjectIDs: []int64{column.ProjectID}, ProjectColumnID: column.ID, + IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")), SortType: issues_model.SortTypeProjectColumnSorting, } diff --git a/services/convert/project.go b/services/convert/project.go index cd6109fd19..8924d8cbe4 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -147,15 +147,17 @@ func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *us func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn { apiColumn := &api.ProjectColumn{ - ID: column.ID, - Title: column.Title, - Default: column.Default, - Sorting: int(column.Sorting), - Color: column.Color, - ProjectID: column.ProjectID, - NumIssues: column.NumIssues, - CreatedAt: column.CreatedUnix.AsTime(), - UpdatedAt: column.UpdatedUnix.AsTime(), + ID: column.ID, + Title: column.Title, + Default: column.Default, + Sorting: int(column.Sorting), + Color: column.Color, + ProjectID: column.ProjectID, + NumIssues: column.NumIssues, + NumOpenIssues: column.NumOpenIssues, + NumClosedIssues: column.NumClosedIssues, + CreatedAt: column.CreatedUnix.AsTime(), + UpdatedAt: column.UpdatedUnix.AsTime(), } if creator, ok := creators[column.CreatorID]; ok { apiColumn.Creator = ToUser(ctx, creator, doer) diff --git a/tests/integration/api_org_project_test.go b/tests/integration/api_org_project_test.go index 51b79d1d3b..cd4385ff5a 100644 --- a/tests/integration/api_org_project_test.go +++ b/tests/integration/api_org_project_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + issue_service "code.gitea.io/gitea/services/issue" projects_service "code.gitea.io/gitea/services/projects" "code.gitea.io/gitea/tests" @@ -432,6 +433,51 @@ func testAPIOrgListProjectColumnIssues(t *testing.T) { DecodeJSON(t, resp, &gotB) assert.Len(t, gotB, 1) assert.Equal(t, issueB.ID, gotB[0].ID) + + // Close issueA, then exercise the state filter (issue #4). + assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, member, "")) + + // default (state omitted) -> open only -> colA returns nothing + req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var openOnly []api.Issue + DecodeJSON(t, resp, &openOnly) + assert.Empty(t, openOnly) + + // state=closed -> colA returns issueA + req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=closed", p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var closedOnly []api.Issue + DecodeJSON(t, resp, &closedOnly) + assert.Len(t, closedOnly, 1) + assert.Equal(t, issueA.ID, closedOnly[0].ID) + + // state=all -> colA returns issueA + req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=all", p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var all []api.Issue + DecodeJSON(t, resp, &all) + assert.Len(t, all, 1) + + // Columns endpoint populates num_issues / num_open_issues / num_closed_issues (issue #5). + req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var listed []*api.ProjectColumn + DecodeJSON(t, resp, &listed) + byID := map[int64]*api.ProjectColumn{} + for _, c := range listed { + byID[c.ID] = c + } + if assert.NotNil(t, byID[colA.ID]) { + assert.EqualValues(t, 1, byID[colA.ID].NumIssues) + assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues) + assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues) + } + if assert.NotNil(t, byID[colB.ID]) { + assert.EqualValues(t, 1, byID[colB.ID].NumIssues) + assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues) + assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues) + } } func testAPIOrgMoveProjectIssue(t *testing.T) { diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 596184fcb8..f88fc5db0b 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + issue_service "code.gitea.io/gitea/services/issue" projects_service "code.gitea.io/gitea/services/projects" "code.gitea.io/gitea/tests" @@ -655,6 +656,57 @@ func testAPIListProjectColumnIssues(t *testing.T) { DecodeJSON(t, resp, &issuesB) assert.Len(t, issuesB, 1) assert.Equal(t, issueB.ID, issuesB[0].ID) + + // Close issueA, then exercise the new state= query parameter. + assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, "")) + + // Default (state omitted) -> open only -> columnA returns nothing. + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var gotOpenOnly []api.Issue + DecodeJSON(t, resp, &gotOpenOnly) + assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA") + + // state=closed -> returns issueA. + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var gotClosed []api.Issue + DecodeJSON(t, resp, &gotClosed) + assert.Len(t, gotClosed, 1) + assert.Equal(t, issueA.ID, gotClosed[0].ID) + + // state=all -> returns issueA. + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var gotAll []api.Issue + DecodeJSON(t, resp, &gotAll) + assert.Len(t, gotAll, 1) + + // And the columns endpoint must populate num_issues / num_open_issues / + // num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open. + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var listed []*api.ProjectColumn + DecodeJSON(t, resp, &listed) + + byID := map[int64]*api.ProjectColumn{} + for _, c := range listed { + byID[c.ID] = c + } + if assert.NotNil(t, byID[columnA.ID]) { + assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total") + assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open") + assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed") + } + if assert.NotNil(t, byID[columnB.ID]) { + assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total") + assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open") + assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed") + } } func testAPIRemoveIssueFromProjectColumn(t *testing.T) { diff --git a/tests/integration/api_user_project_test.go b/tests/integration/api_user_project_test.go index 07c6332c21..be7ecd99a1 100644 --- a/tests/integration/api_user_project_test.go +++ b/tests/integration/api_user_project_test.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + issue_service "code.gitea.io/gitea/services/issue" projects_service "code.gitea.io/gitea/services/projects" "code.gitea.io/gitea/tests" @@ -413,6 +414,51 @@ func testAPIUserListProjectColumnIssues(t *testing.T) { DecodeJSON(t, resp, &gotB) assert.Len(t, gotB, 1) assert.Equal(t, issueB.ID, gotB[0].ID) + + // Close issueA, then exercise the state filter (issue #4). + assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, "")) + + // default (state omitted) -> open only -> colA has nothing + req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var openOnly []api.Issue + DecodeJSON(t, resp, &openOnly) + assert.Empty(t, openOnly) + + // state=closed -> colA returns issueA + req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var closedOnly []api.Issue + DecodeJSON(t, resp, &closedOnly) + assert.Len(t, closedOnly, 1) + assert.Equal(t, issueA.ID, closedOnly[0].ID) + + // state=all -> colA returns issueA + req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var all []api.Issue + DecodeJSON(t, resp, &all) + assert.Len(t, all, 1) + + // Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5). + req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var listed []*api.ProjectColumn + DecodeJSON(t, resp, &listed) + byID := map[int64]*api.ProjectColumn{} + for _, c := range listed { + byID[c.ID] = c + } + if assert.NotNil(t, byID[colA.ID]) { + assert.EqualValues(t, 1, byID[colA.ID].NumIssues) + assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues) + assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues) + } + if assert.NotNil(t, byID[colB.ID]) { + assert.EqualValues(t, 1, byID[colB.ID].NumIssues) + assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues) + assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues) + } } func testAPIUserMoveProjectIssue(t *testing.T) {