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:
@@ -48,7 +48,9 @@ type Column struct {
|
|||||||
ProjectID int64 `xorm:"INDEX NOT NULL"`
|
ProjectID int64 `xorm:"INDEX NOT NULL"`
|
||||||
CreatorID int64 `xorm:"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"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ package project
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CountProjectColumns returns the total number of columns for a project
|
// 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
|
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) {
|
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
|
||||||
columns := make([]*Column, 0, len(columnsIDs))
|
columns := make([]*Column, 0, len(columnsIDs))
|
||||||
if len(columnsIDs) == 0 {
|
if len(columnsIDs) == 0 {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestProjectColumns(t *testing.T) {
|
|||||||
t.Run("CountProjectColumns", testCountProjectColumns)
|
t.Run("CountProjectColumns", testCountProjectColumns)
|
||||||
t.Run("GetProjectColumns", testGetProjectColumns)
|
t.Run("GetProjectColumns", testGetProjectColumns)
|
||||||
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
|
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
|
||||||
|
t.Run("LoadIssueCountsEmpty", testLoadIssueCountsEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountProjectColumns(t *testing.T) {
|
func testCountProjectColumns(t *testing.T) {
|
||||||
@@ -51,6 +52,14 @@ func testGetProjectColumns(t *testing.T) {
|
|||||||
assert.Len(t, allIDs, 3)
|
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) {
|
func testGetColumnsByIDs(t *testing.T) {
|
||||||
project, err := GetProjectByID(t.Context(), 1)
|
project, err := GetProjectByID(t.Context(), 1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ type ProjectColumn struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Default bool `json:"default"`
|
Default bool `json:"default"`
|
||||||
Sorting int `json:"sorting"`
|
Sorting int `json:"sorting"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
ProjectID int64 `json:"project_id"`
|
ProjectID int64 `json:"project_id"`
|
||||||
Creator *User `json:"creator,omitempty"`
|
Creator *User `json:"creator,omitempty"`
|
||||||
NumIssues int64 `json:"num_issues,omitempty"`
|
NumOpenIssues int64 `json:"num_open_issues"`
|
||||||
|
NumClosedIssues int64 `json:"num_closed_issues"`
|
||||||
|
NumIssues int64 `json:"num_issues"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
|
|||||||
@@ -403,6 +403,11 @@ func ListOrgProjectColumns(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := columns.LoadIssueCounts(ctx); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(total)
|
ctx.SetTotalCountHeader(total)
|
||||||
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
||||||
@@ -621,6 +626,10 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
// type: integer
|
// type: integer
|
||||||
// format: int64
|
// format: int64
|
||||||
// required: true
|
// required: true
|
||||||
|
// - name: state
|
||||||
|
// in: query
|
||||||
|
// description: filter issues by state. "open" (default), "closed", or "all".
|
||||||
|
// type: string
|
||||||
// - name: page
|
// - name: page
|
||||||
// in: query
|
// in: query
|
||||||
// description: page number of results to return (1-based)
|
// description: page number of results to return (1-based)
|
||||||
@@ -649,6 +658,7 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
Paginator: &listOptions,
|
Paginator: &listOptions,
|
||||||
ProjectIDs: []int64{column.ProjectID},
|
ProjectIDs: []int64{column.ProjectID},
|
||||||
ProjectColumnID: column.ID,
|
ProjectColumnID: column.ID,
|
||||||
|
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
|
||||||
SortType: issues_model.SortTypeProjectColumnSorting,
|
SortType: issues_model.SortTypeProjectColumnSorting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -435,6 +435,11 @@ func ListProjectColumns(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := columns.LoadIssueCounts(ctx); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(total)
|
ctx.SetTotalCountHeader(total)
|
||||||
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
||||||
@@ -673,6 +678,10 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
// type: integer
|
// type: integer
|
||||||
// format: int64
|
// format: int64
|
||||||
// required: true
|
// required: true
|
||||||
|
// - name: state
|
||||||
|
// in: query
|
||||||
|
// description: filter issues by state. "open" (default), "closed", or "all".
|
||||||
|
// type: string
|
||||||
// - name: page
|
// - name: page
|
||||||
// in: query
|
// in: query
|
||||||
// description: page number of results to return (1-based)
|
// description: page number of results to return (1-based)
|
||||||
@@ -698,6 +707,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||||
ProjectIDs: []int64{column.ProjectID},
|
ProjectIDs: []int64{column.ProjectID},
|
||||||
ProjectColumnID: column.ID,
|
ProjectColumnID: column.ID,
|
||||||
|
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
|
||||||
SortType: issues_model.SortTypeProjectColumnSorting,
|
SortType: issues_model.SortTypeProjectColumnSorting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,11 @@ func ListUserProjectColumns(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := columns.LoadIssueCounts(ctx); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(total)
|
ctx.SetTotalCountHeader(total)
|
||||||
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
||||||
@@ -655,6 +660,10 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
// type: integer
|
// type: integer
|
||||||
// format: int64
|
// format: int64
|
||||||
// required: true
|
// required: true
|
||||||
|
// - name: state
|
||||||
|
// in: query
|
||||||
|
// description: filter issues by state. "open" (default), "closed", or "all".
|
||||||
|
// type: string
|
||||||
// - name: page
|
// - name: page
|
||||||
// in: query
|
// in: query
|
||||||
// description: page number of results to return (1-based)
|
// description: page number of results to return (1-based)
|
||||||
@@ -681,6 +690,7 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
|
|||||||
Paginator: &listOptions,
|
Paginator: &listOptions,
|
||||||
ProjectIDs: []int64{column.ProjectID},
|
ProjectIDs: []int64{column.ProjectID},
|
||||||
ProjectColumnID: column.ID,
|
ProjectColumnID: column.ID,
|
||||||
|
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
|
||||||
SortType: issues_model.SortTypeProjectColumnSorting,
|
SortType: issues_model.SortTypeProjectColumnSorting,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn {
|
||||||
apiColumn := &api.ProjectColumn{
|
apiColumn := &api.ProjectColumn{
|
||||||
ID: column.ID,
|
ID: column.ID,
|
||||||
Title: column.Title,
|
Title: column.Title,
|
||||||
Default: column.Default,
|
Default: column.Default,
|
||||||
Sorting: int(column.Sorting),
|
Sorting: int(column.Sorting),
|
||||||
Color: column.Color,
|
Color: column.Color,
|
||||||
ProjectID: column.ProjectID,
|
ProjectID: column.ProjectID,
|
||||||
NumIssues: column.NumIssues,
|
NumIssues: column.NumIssues,
|
||||||
CreatedAt: column.CreatedUnix.AsTime(),
|
NumOpenIssues: column.NumOpenIssues,
|
||||||
UpdatedAt: column.UpdatedUnix.AsTime(),
|
NumClosedIssues: column.NumClosedIssues,
|
||||||
|
CreatedAt: column.CreatedUnix.AsTime(),
|
||||||
|
UpdatedAt: column.UpdatedUnix.AsTime(),
|
||||||
}
|
}
|
||||||
if creator, ok := creators[column.CreatorID]; ok {
|
if creator, ok := creators[column.CreatorID]; ok {
|
||||||
apiColumn.Creator = ToUser(ctx, creator, doer)
|
apiColumn.Creator = ToUser(ctx, creator, doer)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
issue_service "code.gitea.io/gitea/services/issue"
|
||||||
projects_service "code.gitea.io/gitea/services/projects"
|
projects_service "code.gitea.io/gitea/services/projects"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
@@ -432,6 +433,51 @@ func testAPIOrgListProjectColumnIssues(t *testing.T) {
|
|||||||
DecodeJSON(t, resp, &gotB)
|
DecodeJSON(t, resp, &gotB)
|
||||||
assert.Len(t, gotB, 1)
|
assert.Len(t, gotB, 1)
|
||||||
assert.Equal(t, issueB.ID, gotB[0].ID)
|
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) {
|
func testAPIOrgMoveProjectIssue(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
issue_service "code.gitea.io/gitea/services/issue"
|
||||||
projects_service "code.gitea.io/gitea/services/projects"
|
projects_service "code.gitea.io/gitea/services/projects"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
@@ -655,6 +656,57 @@ func testAPIListProjectColumnIssues(t *testing.T) {
|
|||||||
DecodeJSON(t, resp, &issuesB)
|
DecodeJSON(t, resp, &issuesB)
|
||||||
assert.Len(t, issuesB, 1)
|
assert.Len(t, issuesB, 1)
|
||||||
assert.Equal(t, issueB.ID, issuesB[0].ID)
|
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) {
|
func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
issue_service "code.gitea.io/gitea/services/issue"
|
||||||
projects_service "code.gitea.io/gitea/services/projects"
|
projects_service "code.gitea.io/gitea/services/projects"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
@@ -413,6 +414,51 @@ func testAPIUserListProjectColumnIssues(t *testing.T) {
|
|||||||
DecodeJSON(t, resp, &gotB)
|
DecodeJSON(t, resp, &gotB)
|
||||||
assert.Len(t, gotB, 1)
|
assert.Len(t, gotB, 1)
|
||||||
assert.Equal(t, issueB.ID, gotB[0].ID)
|
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) {
|
func testAPIUserMoveProjectIssue(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user