feat(api): state filter + populated num_issues on project columns (#6)

This commit was merged in pull request #6.
This commit is contained in:
2026-05-15 22:00:51 +03:00
parent 580fe2df32
commit 15acfdb783
11 changed files with 258 additions and 14 deletions
+3 -1
View File
@@ -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"`
+55
View File
@@ -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 {
+9
View File
@@ -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)