feat(api): state filter + populated num_issues on project columns
Adds two improvements to the user/org/repo project-board REST API: * state filter on column-issues endpoints (issue #4) GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues Now accept ?state=open|closed|all (default open), matching the convention on the project-list endpoint and on /repos/.../issues. Applied at the IssuesOptions layer so all three scopes inherit the filter. * populated num_issues / num_open_issues / num_closed_issues on column-list (issue #5) ColumnList.LoadIssueCounts runs two grouped queries against project_issue joined with issue (one open, one closed). All three List*Columns handlers call it before converting, so num_issues stops being null and consumers can render a kanban summary in a single round trip instead of N+1. Tests: * unit: empty-input fast path on ColumnList.LoadIssueCounts. * integration: extended testAPIListProjectColumnIssues / -User / -Org to close an issue, then verify default=open hides it, state=closed and state=all return it, and the column-list response carries the correct open/closed/total split. Closes #4, closes #5
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user