MUL-6878: avoid per-row issue status resolution (#7814)

* perf(server): avoid per-row issue status resolution

Co-authored-by: multica-agent <github@multica.ai>

* fix(server): address issue status query review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Sol-Boy <sol-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Multica Eve
2026-08-31 16:00:45 +08:00
committed by GitHub
co-authored by multica-agent Sol-Boy
parent 1dd6b9ecdb
commit db86c75e12
22 changed files with 509 additions and 118 deletions
+1
View File
@@ -274,6 +274,7 @@ var concurrentIndexCleanups = map[string]string{
"438_agent_runtime_online_last_seen_index": "idx_agent_runtime_online_last_seen",
"439_agent_runtime_offline_last_seen_index": "idx_agent_runtime_offline_last_seen",
"440_github_pr_head_sha_index": "idx_github_pull_request_head_sha",
"443_issue_project_status_index": "idx_issue_project_status",
}
// concurrentDownIndexCleanups covers every migration whose down direction
+8 -2
View File
@@ -450,9 +450,15 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request)
return
}
terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
return
}
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive completed inbox")
+47
View File
@@ -163,3 +163,50 @@ func TestArchiveAllReadInboxUsesNewestIssueRow(t *testing.T) {
t.Fatalf("archived rows in unread issue = %d, want the whole group untouched", got)
}
}
func TestArchiveCompletedInboxExpandsCustomTerminalStatuses(t *testing.T) {
workspaceID := dbfx.Workspace(t, "Archive custom completed", "archive-custom-completed-"+uuid.NewString())
dbfx.Member(t, workspaceID, testUserID, "owner")
dbfx.Insert(t, "issue_status", testutil.Cols{
"workspace_id": workspaceID,
"key": "verified_complete",
"name": "Verified complete",
"category": "done",
"color": "#22c55e",
"is_system": false,
"position": 1,
})
completedIssueID := dbfx.Issue(t, "Custom completed issue", testutil.Cols{
"workspace_id": workspaceID,
"status": "verified_complete",
})
openIssueID := dbfx.Issue(t, "Open issue", testutil.Cols{
"workspace_id": workspaceID,
"status": "todo",
})
for _, issueID := range []string{completedIssueID, openIssueID} {
dbfx.Insert(t, "inbox_item", testutil.Cols{
"workspace_id": workspaceID,
"recipient_type": "member",
"recipient_id": testUserID,
"type": "status_changed",
"severity": "info",
"issue_id": issueID,
"title": "Status changed",
"archived": false,
})
}
testutil.Call(t, inboxWorkspaceHandler(testHandler.ArchiveCompletedInbox),
inboxRequest(http.MethodPost, "/api/inbox/archive-completed", workspaceID)).
Want(http.StatusOK)
if got := dbfx.Count(t,
"SELECT count(*) FROM inbox_item WHERE issue_id = $1 AND archived = true", completedIssueID); got != 1 {
t.Fatalf("archived rows for custom completed issue = %d, want 1", got)
}
if got := dbfx.Count(t,
"SELECT count(*) FROM inbox_item WHERE issue_id = $1 AND archived = true", openIssueID); got != 0 {
t.Fatalf("archived rows for open issue = %d, want 0", got)
}
}
+40 -13
View File
@@ -619,7 +619,7 @@ type searchResult struct {
// It uses LOWER(column) LIKE for case-insensitive matching compatible with pg_bigm 1.2 GIN indexes.
// Search patterns are lowercased in Go to avoid redundant LOWER() on the pattern side in SQL.
// LIKE patterns are pre-built in Go (e.g. "%html%") so pg_bigm can extract bigrams from a single parameter value.
func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool) (string, []any) {
func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool, terminalStatusKeys []string) (string, []any) {
// Lowercase in Go so SQL only needs LOWER() on the column side.
phrase = strings.ToLower(phrase)
for i, t := range terms {
@@ -701,7 +701,10 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool,
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
if !includeClosed {
whereClause += " AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')"
// Negate only known terminal keys so an unknown legacy key remains
// searchable instead of disappearing from the default result set.
terminalStatusesParam := nextArg(terminalStatusKeys)
whereClause += fmt.Sprintf(" AND NOT (i.status = ANY(%s::text[]))", terminalStatusesParam)
}
// --- ORDER BY clause ---
@@ -912,8 +915,18 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
}
terms := splitSearchTerms(q)
queryNum, hasNum := parseQueryNumber(q)
var terminalStatusKeys []string
if !includeClosed {
resolvedKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID)
if err != nil {
slog.Warn("expand terminal status categories failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
return
}
terminalStatusKeys = resolvedKeys
}
sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed)
sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed, terminalStatusKeys)
// Fill placeholder args: $4 = workspace_id, last two = limit, offset
args[3] = wsUUID
args[len(args)-2] = limit
@@ -1127,16 +1140,22 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
openPropertiesFilter = marshaled
}
terminalStatusKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
return
}
issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
WorkspaceID: wsUUID,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
InvolvesUserID: involvesUserFilter,
MetadataFilter: metadataFilter,
PropertiesFilter: openPropertiesFilter,
WorkspaceID: wsUUID,
TerminalStatusKeys: terminalStatusKeys,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
InvolvesUserID: involvesUserFilter,
MetadataFilter: metadataFilter,
PropertiesFilter: openPropertiesFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
@@ -2371,7 +2390,15 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID)
terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve status categories")
return
}
rows, err := h.Queries.ChildIssueProgress(r.Context(), db.ChildIssueProgressParams{
WorkspaceID: wsUUID,
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get child issue progress")
return
+10
View File
@@ -40,6 +40,16 @@ type IssueStatusResponse struct {
UpdatedAt string `json:"updated_at"`
}
// terminalIssueStatusKeys resolves the concrete status keys whose categories
// carry terminal behavior. Callers pass the result into indexed status
// predicates instead of resolving the category once per issue row.
func (h *Handler) terminalIssueStatusKeys(ctx context.Context, workspaceID pgtype.UUID) ([]string, error) {
return issuestatus.ExpandCategories(ctx, h.Queries, workspaceID, []string{
issuestatus.Done,
issuestatus.Cancelled,
})
}
func issueStatusToResponse(s db.IssueStatus) IssueStatusResponse {
return IssueStatusResponse{
ID: uuidToString(s.ID),
+64 -8
View File
@@ -413,13 +413,31 @@ func TestIssueWriteStoresCanonicalStatusKey(t *testing.T) {
}
// TestCustomTerminalStatusCountsAsTerminalInSQL covers the SQL-side consumers
// the Go resolver cannot reach. Before issue_effective_status existed, each of
// these read the status literal, so a custom status in the `done` category
// still counted as open for the duplicate guard and was missed by sub-issue and
// project completion counts.
// the Go resolver cannot reach. Terminal categories are expanded once into
// concrete keys so custom done statuses preserve their behavior without a
// per-row issue_effective_status call.
func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "gate_approved_s", issuestatus.Done)
createTestCustomStatus(t, "gate_archived_s", issuestatus.Done)
if _, err := testPool.Exec(ctx, `
UPDATE issue_status
SET archived_at = now()
WHERE workspace_id = $1 AND key = 'gate_archived_s'`, parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("archive custom terminal status: %v", err)
}
terminalStatusKeys, err := issuestatus.ExpandCategories(
ctx,
testHandler.Queries,
parseUUID(testWorkspaceID),
[]string{issuestatus.Done, issuestatus.Cancelled},
)
if err != nil {
t.Fatalf("expand terminal status categories: %v", err)
}
if !slices.Contains(terminalStatusKeys, "gate_archived_s") {
t.Fatalf("expanded terminal keys %v do not include archived custom status", terminalStatusKeys)
}
mkIssue := func(title, status string) pgtype.UUID {
t.Helper()
@@ -443,13 +461,38 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) {
title := "sql terminal duplicate probe"
mkIssue(title, "gate_approved_s")
if _, err := testHandler.Queries.FindActiveDuplicateIssue(ctx, db.FindActiveDuplicateIssueParams{
WorkspaceID: parseUUID(testWorkspaceID),
NormalizedTitle: title,
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
NormalizedTitle: title,
}); err == nil {
t.Error("an issue on a custom done status must not count as an active duplicate")
}
})
t.Run("open issue listing excludes it", func(t *testing.T) {
customDoneID := mkIssue("sql open-list custom done", "gate_approved_s")
openID := mkIssue("sql open-list active", "todo")
unknownID := parseUUID(dbfx.Issue(t, "sql open-list unknown legacy status", testutil.Cols{
"status": "legacy_unknown",
}))
rows, err := testHandler.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("ListOpenIssues: %v", err)
}
var sawCustomDone, sawOpen, sawUnknown bool
for _, row := range rows {
sawCustomDone = sawCustomDone || row.ID == customDoneID
sawOpen = sawOpen || row.ID == openID
sawUnknown = sawUnknown || row.ID == unknownID
}
if sawCustomDone || !sawOpen || !sawUnknown {
t.Fatalf("open listing customDone/open/unknown = %v/%v/%v, want false/true/true", sawCustomDone, sawOpen, sawUnknown)
}
})
t.Run("project completion counts it as done", func(t *testing.T) {
var projectID pgtype.UUID
if err := testPool.QueryRow(ctx,
@@ -467,8 +510,18 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) {
t.Fatalf("attach to project: %v", err)
}
}
foreignWorkspaceID := dbfx.Workspace(t, "Foreign project stats", fmt.Sprintf("foreign-project-stats-%d", time.Now().UnixNano()))
dbfx.Issue(t, "foreign workspace issue with mismatched project", testutil.Cols{
"workspace_id": foreignWorkspaceID,
"project_id": uuidToString(projectID),
"status": "todo",
})
stats, err := testHandler.Queries.GetProjectIssueStats(ctx, []pgtype.UUID{projectID})
stats, err := testHandler.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{
WorkspaceID: parseUUID(testWorkspaceID),
ProjectIds: []pgtype.UUID{projectID},
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("GetProjectIssueStats: %v", err)
}
@@ -492,7 +545,10 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) {
}
}
rows, err := testHandler.Queries.ChildIssueProgress(ctx, parseUUID(testWorkspaceID))
rows, err := testHandler.Queries.ChildIssueProgress(ctx, db.ChildIssueProgressParams{
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("ChildIssueProgress: %v", err)
}
+37 -8
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -63,14 +64,32 @@ func projectToResponse(p db.Project) ProjectResponse {
}
}
func (h *Handler) loadProjectIssueStats(ctx context.Context, projectID pgtype.UUID) (int64, int64) {
stats, err := h.Queries.GetProjectIssueStats(ctx, []pgtype.UUID{projectID})
func (h *Handler) loadProjectIssueStats(ctx context.Context, workspaceID, projectID pgtype.UUID) (int64, int64) {
terminalStatusKeys := h.projectTerminalIssueStatusKeys(ctx, workspaceID)
stats, err := h.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{
WorkspaceID: workspaceID,
ProjectIds: []pgtype.UUID{projectID},
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil || len(stats) == 0 {
return 0, 0
}
return stats[0].TotalCount, stats[0].DoneCount
}
// projectTerminalIssueStatusKeys keeps project responses useful if the custom
// status catalog cannot be read. Canonical terminal keys are less complete
// than the workspace catalog, but they avoid rendering every project as 0/0.
func (h *Handler) projectTerminalIssueStatusKeys(ctx context.Context, workspaceID pgtype.UUID) []string {
keys, err := h.terminalIssueStatusKeys(ctx, workspaceID)
if err == nil {
return keys
}
slog.Warn("expand project terminal status categories failed; using canonical keys",
"workspace_id", uuidToString(workspaceID), "error", err)
return []string{issuestatus.Done, issuestatus.Cancelled}
}
func (h *Handler) loadProjectResourceCount(ctx context.Context, projectID pgtype.UUID) int64 {
rows, err := h.Queries.GetProjectResourceCounts(ctx, []pgtype.UUID{projectID})
if err != nil || len(rows) == 0 {
@@ -146,8 +165,13 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
for i, p := range projects {
projectIDs[i] = p.ID
}
stats, err := h.Queries.GetProjectIssueStats(r.Context(), projectIDs)
if err == nil {
terminalStatusKeys := h.projectTerminalIssueStatusKeys(r.Context(), wsUUID)
stats, statsErr := h.Queries.GetProjectIssueStats(r.Context(), db.GetProjectIssueStatsParams{
WorkspaceID: wsUUID,
ProjectIds: projectIDs,
TerminalStatusKeys: terminalStatusKeys,
})
if statsErr == nil {
for _, s := range stats {
statsMap[uuidToString(s.ProjectID)] = s
}
@@ -191,7 +215,7 @@ func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
return
}
resp := projectToResponse(project)
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID)
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID)
resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID)
writeJSON(w, http.StatusOK, resp)
}
@@ -567,7 +591,7 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
return
}
resp := projectToResponse(project)
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID)
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID)
resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID)
h.publish(protocol.EventProjectUpdated, workspaceID, "member", userID, map[string]any{"project": resp})
writeJSON(w, http.StatusOK, resp)
@@ -896,8 +920,13 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
for i, r := range results {
projectIDs[i] = r.project.ID
}
stats, err := h.Queries.GetProjectIssueStats(ctx, projectIDs)
if err == nil {
terminalStatusKeys := h.projectTerminalIssueStatusKeys(ctx, wsUUID)
stats, statsErr := h.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{
WorkspaceID: wsUUID,
ProjectIds: projectIDs,
TerminalStatusKeys: terminalStatusKeys,
})
if statsErr == nil {
for _, s := range stats {
statsMap[uuidToString(s.ProjectID)] = s
}
@@ -0,0 +1,26 @@
package handler
import (
"context"
"errors"
"slices"
"testing"
"github.com/multica-ai/multica/server/internal/issuestatus"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestProjectTerminalIssueStatusKeysFallsBackToCanonicalKeys(t *testing.T) {
h := *testHandler
h.Queries = db.New(failQueryDBTX{
DBTX: testPool,
failOn: "SELECT key FROM issue_status",
err: errors.New("status catalog unavailable"),
})
got := h.projectTerminalIssueStatusKeys(context.Background(), parseUUID(testWorkspaceID))
want := []string{issuestatus.Done, issuestatus.Cancelled}
if !slices.Equal(got, want) {
t.Fatalf("project terminal keys = %v, want canonical fallback %v", got, want)
}
}
+42 -14
View File
@@ -6,7 +6,7 @@ import (
)
func TestBuildSearchQuery_SingleTerm(t *testing.T) {
query, args := buildSearchQuery("Hello", []string{"Hello"}, 0, false, false)
query, args := buildSearchQuery("Hello", []string{"Hello"}, 0, false, false, []string{"done", "cancelled"})
// Pattern should be lowercased in Go.
if args[0] != "hello" {
@@ -36,13 +36,41 @@ func TestBuildSearchQuery_SingleTerm(t *testing.T) {
}
// Should exclude closed issues by default.
if !strings.Contains(query, "NOT IN ('done', 'cancelled')") {
t.Error("query should exclude done/cancelled when includeClosed=false")
if !strings.Contains(query, "NOT (i.status = ANY(") {
t.Error("query should exclude the expanded terminal status keys when includeClosed=false")
}
if strings.Contains(query, "issue_effective_status") {
t.Error("query should not resolve status categories once per issue row")
}
}
func TestBuildSearchQuery_CustomTerminalStatuses(t *testing.T) {
terminalStatusKeys := []string{"done", "cancelled", "verified"}
query, args := buildSearchQuery(
"Hello",
[]string{"Hello"},
0,
false,
false,
terminalStatusKeys,
)
if !strings.Contains(query, "NOT (i.status = ANY($5::text[]))") {
t.Fatalf("query does not filter through the expanded terminal keys:\n%s", query)
}
got, ok := args[4].([]string)
if !ok || len(got) != len(terminalStatusKeys) {
t.Fatalf("terminal status argument = %#v, want %#v", args[4], terminalStatusKeys)
}
for i := range terminalStatusKeys {
if got[i] != terminalStatusKeys[i] {
t.Fatalf("terminal status argument = %#v, want %#v", got, terminalStatusKeys)
}
}
}
func TestBuildSearchQuery_MultiTerm(t *testing.T) {
query, args := buildSearchQuery("Foo Bar", []string{"Foo", "Bar"}, 0, false, false)
query, args := buildSearchQuery("Foo Bar", []string{"Foo", "Bar"}, 0, false, false, []string{"done", "cancelled"})
// Both phrase and terms should be lowercased.
if args[0] != "foo bar" {
@@ -63,7 +91,7 @@ func TestBuildSearchQuery_MultiTerm(t *testing.T) {
}
func TestBuildSearchQuery_WithNumber(t *testing.T) {
query, args := buildSearchQuery("MUL-42", []string{"MUL-42"}, 42, true, false)
query, args := buildSearchQuery("MUL-42", []string{"MUL-42"}, 42, true, false, []string{"done", "cancelled"})
_ = args
// Number match should be in WHERE.
@@ -77,15 +105,15 @@ func TestBuildSearchQuery_WithNumber(t *testing.T) {
}
func TestBuildSearchQuery_IncludeClosed(t *testing.T) {
query, _ := buildSearchQuery("test", []string{"test"}, 0, false, true)
query, _ := buildSearchQuery("test", []string{"test"}, 0, false, true, nil)
if strings.Contains(query, "NOT IN ('done', 'cancelled')") {
if strings.Contains(query, "i.status = ANY(") {
t.Error("query should not exclude done/cancelled when includeClosed=true")
}
}
func TestBuildSearchQuery_SpecialChars(t *testing.T) {
query, args := buildSearchQuery("100%", []string{"100%"}, 0, false, false)
query, args := buildSearchQuery("100%", []string{"100%"}, 0, false, false, []string{"done", "cancelled"})
_ = query
// % should be escaped in the phrase arg.
@@ -204,7 +232,7 @@ func TestExtractSnippet_CJKContent(t *testing.T) {
// --- Ranking regression tests ---
func TestBuildSearchQuery_CommentRankTiers(t *testing.T) {
query, _ := buildSearchQuery("test phrase", []string{"test", "phrase"}, 0, false, false)
query, _ := buildSearchQuery("test phrase", []string{"test", "phrase"}, 0, false, false, []string{"done", "cancelled"})
// Comment phrase match should be tier 7
if !strings.Contains(query, "THEN 7") {
@@ -221,7 +249,7 @@ func TestBuildSearchQuery_CommentRankTiers(t *testing.T) {
}
func TestBuildSearchQuery_DescriptionRankTiers(t *testing.T) {
query, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false)
query, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false, []string{"done", "cancelled"})
// Description phrase match should be tier 5
if !strings.Contains(query, "THEN 5") {
@@ -234,7 +262,7 @@ func TestBuildSearchQuery_DescriptionRankTiers(t *testing.T) {
}
func TestBuildSearchQuery_SingleTermNoAllTermTiers(t *testing.T) {
query, _ := buildSearchQuery("html", []string{"html"}, 0, false, false)
query, _ := buildSearchQuery("html", []string{"html"}, 0, false, false, []string{"done", "cancelled"})
// Extract the rank CASE expression (ends with "ELSE 9 END") to avoid
// false matches against statusRank which also contains THEN 4/6.
@@ -268,7 +296,7 @@ func TestBuildSearchQuery_SingleTermNoAllTermTiers(t *testing.T) {
// $4 is buildSearchQuery's canonical workspace_id placeholder (the
// caller writes wsUUID into args[3] before executing).
func TestBuildSearchQuery_CommentSubqueryWorkspaceScope(t *testing.T) {
singleQuery, _ := buildSearchQuery("html", []string{"html"}, 0, false, false)
singleQuery, _ := buildSearchQuery("html", []string{"html"}, 0, false, false, []string{"done", "cancelled"})
// Every occurrence of `FROM comment c` must be followed by the
// c.workspace_id = $4 constraint. Counting is safer than a single
@@ -286,7 +314,7 @@ func TestBuildSearchQuery_CommentSubqueryWorkspaceScope(t *testing.T) {
// Multi-term uses one extra comment subquery in the WHERE and one in
// the rank CASE for the all-terms match — same invariant applies.
multiQuery, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false)
multiQuery, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false, []string{"done", "cancelled"})
fromCountMulti := strings.Count(multiQuery, "FROM comment c")
scopedCountMulti := strings.Count(multiQuery, "c.workspace_id = $4")
if scopedCountMulti < fromCountMulti {
@@ -398,6 +426,6 @@ func TestBuildProjectSearchQuery_CancelledDemotedAheadOfRelevance(t *testing.T)
// keeps each test's literals independent.
func buildSearchQueryForTest(t *testing.T, phrase string, terms []string, num int, hasNum bool, includeClosed bool) string {
t.Helper()
query, _ := buildSearchQuery(phrase, append([]string(nil), terms...), num, hasNum, includeClosed)
query, _ := buildSearchQuery(phrase, append([]string(nil), terms...), num, hasNum, includeClosed, []string{"done", "cancelled"})
return query
}
+26 -9
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -60,12 +61,20 @@ func LockAndFindActiveDuplicate(
if allowDuplicate {
return db.Issue{}, false, nil
}
terminalStatusKeys, err := issuestatus.ExpandCategories(ctx, q, workspaceID, []string{
issuestatus.Done,
issuestatus.Cancelled,
})
if err != nil {
return db.Issue{}, false, err
}
duplicate, err := q.FindActiveDuplicateIssue(ctx, db.FindActiveDuplicateIssueParams{
WorkspaceID: workspaceID,
ProjectID: projectID,
ParentIssueID: parentIssueID,
NormalizedTitle: normalizedTitle,
WorkspaceID: workspaceID,
TerminalStatusKeys: terminalStatusKeys,
ProjectID: projectID,
ParentIssueID: parentIssueID,
NormalizedTitle: normalizedTitle,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -92,13 +101,21 @@ func LockAndFindRecentAutopilotDuplicate(
if err := q.LockIssueDuplicateKey(ctx, recentAutopilotLockKey(workspaceID, autopilotID, projectID, normalizedTitle)); err != nil {
return db.Issue{}, false, err
}
terminalStatusKeys, err := issuestatus.ExpandCategories(ctx, q, workspaceID, []string{
issuestatus.Done,
issuestatus.Cancelled,
})
if err != nil {
return db.Issue{}, false, err
}
duplicate, err := q.FindRecentAutopilotDuplicateIssue(ctx, db.FindRecentAutopilotDuplicateIssueParams{
WorkspaceID: workspaceID,
OriginID: autopilotID,
ProjectID: projectID,
NormalizedTitle: normalizedTitle,
CreatedAfter: pgtype.Timestamptz{Time: time.Now().UTC().Add(-window), Valid: true},
WorkspaceID: workspaceID,
TerminalStatusKeys: terminalStatusKeys,
OriginID: autopilotID,
ProjectID: projectID,
NormalizedTitle: normalizedTitle,
CreatedAfter: pgtype.Timestamptz{Time: time.Now().UTC().Add(-window), Valid: true},
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -0,0 +1,65 @@
package issueguard
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type catalogFailureDB struct {
err error
}
func (f catalogFailureDB) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag("SELECT 1"), nil
}
func (f catalogFailureDB) Query(context.Context, string, ...any) (pgx.Rows, error) {
return nil, f.err
}
func (f catalogFailureDB) QueryRow(context.Context, string, ...any) pgx.Row {
return catalogFailureRow{err: f.err}
}
type catalogFailureRow struct {
err error
}
func (r catalogFailureRow) Scan(...any) error { return r.err }
func TestLockAndFindActiveDuplicatePropagatesStatusCatalogFailure(t *testing.T) {
catalogErr := errors.New("status catalog unavailable")
q := db.New(catalogFailureDB{err: catalogErr})
_, found, err := LockAndFindActiveDuplicate(
context.Background(), q, testUUID(1), pgtype.UUID{}, pgtype.UUID{}, "duplicate title", false,
)
if !errors.Is(err, catalogErr) || found {
t.Fatalf("LockAndFindActiveDuplicate = found %v, err %v; want false, catalog error", found, err)
}
}
func TestLockAndFindRecentAutopilotDuplicatePropagatesStatusCatalogFailure(t *testing.T) {
catalogErr := errors.New("status catalog unavailable")
q := db.New(catalogFailureDB{err: catalogErr})
_, found, err := LockAndFindRecentAutopilotDuplicate(
context.Background(), q, testUUID(1), testUUID(2), pgtype.UUID{}, "duplicate title", time.Hour,
)
if !errors.Is(err, catalogErr) || found {
t.Fatalf("LockAndFindRecentAutopilotDuplicate = found %v, err %v; want false, catalog error", found, err)
}
}
func testUUID(lastByte byte) pgtype.UUID {
var value [16]byte
value[len(value)-1] = lastByte
return pgtype.UUID{Bytes: value, Valid: true}
}
@@ -262,6 +262,46 @@ func TestPendingDelegatedFailureSweepRepairsCommittedCommentWithoutTask(t *testi
}
}
func TestPendingDelegatedFailureSweepSkipsCustomTerminalSourceIssue(t *testing.T) {
f, svc := seedDelegatedFailureFixture(t)
ctx := context.Background()
failedID := f.insertWorkerTask(t, "failed", "comment", 1, 2)
if _, err := f.pool.Exec(ctx, `
UPDATE agent_task_queue
SET failure_reason = 'agent_error.process_failure', error = 'worker exited', completed_at = now()
WHERE id = $1`, failedID); err != nil {
t.Fatalf("stamp failed task: %v", err)
}
if target, created, err := svc.ensureDelegatedFailureRecoveryComment(ctx, failedID); err != nil || target == nil || !created {
t.Fatalf("ensure recovery comment = target %v created %v err %v", target != nil, created, err)
}
const customDone = "recovery_verified"
if _, err := f.pool.Exec(ctx, `
INSERT INTO issue_status (
workspace_id, key, name, description, category, color, is_system, position
) VALUES ($1, $2, 'Recovery verified', '', 'done', '#22c55e', false, 1)`,
f.workspaceID, customDone); err != nil {
t.Fatalf("insert custom done status: %v", err)
}
if _, err := f.pool.Exec(ctx, `UPDATE issue SET status = $2 WHERE id = $1`, f.issueID, customDone); err != nil {
t.Fatalf("move source issue to custom done status: %v", err)
}
if result, err := svc.RecoverPendingDelegatedFailures(ctx, 100); err != nil || result != (DelegatedFailureRecoverySweepResult{}) {
t.Fatalf("terminal-source recovery sweep = %+v, %v; want zero result, nil", result, err)
}
var recoveryTasks int
if err := f.pool.QueryRow(ctx, `
SELECT count(*) FROM agent_task_queue
WHERE trigger_evidence_kind = 'delegated_failure' AND trigger_evidence_ref_id = $1`, failedID).Scan(&recoveryTasks); err != nil {
t.Fatalf("count recovery tasks: %v", err)
}
if recoveryTasks != 0 {
t.Fatalf("recovery tasks = %d, want none for a custom terminal source issue", recoveryTasks)
}
}
func TestPendingDelegatedFailureSweepRequeuesTerminalUndeliveredTask(t *testing.T) {
for _, terminalStatus := range []string{"failed", "cancelled"} {
t.Run(terminalStatus, func(t *testing.T) {
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_issue_project_status;
@@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_issue_project_status
ON issue (project_id, workspace_id, status);
+4 -1
View File
@@ -5695,6 +5695,9 @@ JOIN agent_task_queue failed ON failed.id = recovery.source_task_id
JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id
JOIN issue source_issue ON source_issue.id = source.issue_id
JOIN agent source_agent ON source_agent.id = source.agent_id
LEFT JOIN issue_status source_status
ON source_status.workspace_id = source_issue.workspace_id
AND source_status.key = source_issue.status
WHERE recovery.author_type = 'system'
AND recovery.type = 'progress_update'
AND recovery.source_task_id IS NOT NULL
@@ -5707,7 +5710,7 @@ WHERE recovery.author_type = 'system'
AND source.autopilot_run_id IS NULL
AND source.issue_id IS NOT NULL
AND source.agent_id <> failed.agent_id
AND issue_effective_status(source_issue.workspace_id, source_issue.status) NOT IN ('done', 'cancelled', 'backlog')
AND COALESCE(source_status.category, source_issue.status) NOT IN ('done', 'cancelled', 'backlog')
AND source_agent.archived_at IS NULL
AND source_agent.runtime_id IS NOT NULL
AND source_agent.workspace_id = source_issue.workspace_id
+5 -4
View File
@@ -78,17 +78,18 @@ WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $
AND i.issue_id IN (
SELECT id FROM issue
WHERE workspace_id = $1
AND issue_effective_status(workspace_id, status) IN ('done', 'cancelled')
AND status = ANY($3::text[])
)
`
type ArchiveCompletedInboxParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
TerminalStatusKeys []string `json:"terminal_status_keys"`
}
func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveCompletedInboxParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID)
result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID, arg.TerminalStatusKeys)
if err != nil {
return 0, err
}
+61 -45
View File
@@ -14,21 +14,26 @@ import (
const childIssueProgress = `-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done
COUNT(*) FILTER (WHERE status = ANY($2::text[]))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
GROUP BY parent_issue_id
`
type ChildIssueProgressParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
TerminalStatusKeys []string `json:"terminal_status_keys"`
}
type ChildIssueProgressRow struct {
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Total int64 `json:"total"`
Done int64 `json:"done"`
}
func (q *Queries) ChildIssueProgress(ctx context.Context, workspaceID pgtype.UUID) ([]ChildIssueProgressRow, error) {
rows, err := q.db.Query(ctx, childIssueProgress, workspaceID)
func (q *Queries) ChildIssueProgress(ctx context.Context, arg ChildIssueProgressParams) ([]ChildIssueProgressRow, error) {
rows, err := q.db.Query(ctx, childIssueProgress, arg.WorkspaceID, arg.TerminalStatusKeys)
if err != nil {
return nil, err
}
@@ -536,24 +541,27 @@ func (q *Queries) DetachDirectChildIssues(ctx context.Context, arg DetachDirectC
const findActiveDuplicateIssue = `-- name: FindActiveDuplicateIssue :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at FROM issue
WHERE workspace_id = $1
AND issue_effective_status(workspace_id, status) NOT IN ('done', 'cancelled')
AND project_id IS NOT DISTINCT FROM $2::uuid
AND parent_issue_id IS NOT DISTINCT FROM $3::uuid
AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = $4
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (status = ANY($2::text[]))
AND project_id IS NOT DISTINCT FROM $3::uuid
AND parent_issue_id IS NOT DISTINCT FROM $4::uuid
AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = $5
ORDER BY created_at ASC
LIMIT 1
`
type FindActiveDuplicateIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
NormalizedTitle string `json:"normalized_title"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
TerminalStatusKeys []string `json:"terminal_status_keys"`
ProjectID pgtype.UUID `json:"project_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
NormalizedTitle string `json:"normalized_title"`
}
func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDuplicateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, findActiveDuplicateIssue,
arg.WorkspaceID,
arg.TerminalStatusKeys,
arg.ProjectID,
arg.ParentIssueID,
arg.NormalizedTitle,
@@ -595,12 +603,13 @@ func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDu
const findRecentAutopilotDuplicateIssue = `-- name: FindRecentAutopilotDuplicateIssue :one
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.origin_type, i.origin_id, i.first_executed_at, i.start_date, i.metadata, i.stage, i.properties, i.revision, i.last_activity_at FROM issue i
WHERE i.workspace_id = $1
AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (i.status = ANY($3::text[]))
AND i.origin_type = 'autopilot'
AND i.origin_id = $2
AND i.project_id IS NOT DISTINCT FROM $3::uuid
AND lower(btrim(regexp_replace(i.title, '[[:space:]]+', ' ', 'g'))) = $4
AND i.created_at >= $5::timestamptz
AND i.project_id IS NOT DISTINCT FROM $4::uuid
AND lower(btrim(regexp_replace(i.title, '[[:space:]]+', ' ', 'g'))) = $5
AND i.created_at >= $6::timestamptz
AND EXISTS (
SELECT 1
FROM autopilot_run r
@@ -613,17 +622,19 @@ LIMIT 1
`
type FindRecentAutopilotDuplicateIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
OriginID pgtype.UUID `json:"origin_id"`
ProjectID pgtype.UUID `json:"project_id"`
NormalizedTitle string `json:"normalized_title"`
CreatedAfter pgtype.Timestamptz `json:"created_after"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OriginID pgtype.UUID `json:"origin_id"`
TerminalStatusKeys []string `json:"terminal_status_keys"`
ProjectID pgtype.UUID `json:"project_id"`
NormalizedTitle string `json:"normalized_title"`
CreatedAfter pgtype.Timestamptz `json:"created_after"`
}
func (q *Queries) FindRecentAutopilotDuplicateIssue(ctx context.Context, arg FindRecentAutopilotDuplicateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, findRecentAutopilotDuplicateIssue,
arg.WorkspaceID,
arg.OriginID,
arg.TerminalStatusKeys,
arg.ProjectID,
arg.NormalizedTitle,
arg.CreatedAfter,
@@ -1206,25 +1217,28 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
i.revision
FROM issue i
WHERE i.workspace_id = $1
AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')
AND ($2::text IS NULL OR i.priority = $2)
AND ($3::uuid IS NULL OR i.assignee_id = $3)
AND ($4::uuid[] IS NULL OR i.assignee_id = ANY($4::uuid[]))
AND ($5::uuid IS NULL OR i.creator_id = $5)
AND ($6::uuid IS NULL OR i.project_id = $6)
AND ($7::jsonb IS NULL OR i.metadata @> $7::jsonb)
-- Negate only known terminal keys so an unknown legacy key remains visible.
AND NOT (i.status = ANY($2::text[]))
AND ($3::text IS NULL OR i.priority = $3)
AND ($4::uuid IS NULL OR i.assignee_id = $4)
AND ($5::uuid[] IS NULL OR i.assignee_id = ANY($5::uuid[]))
AND ($6::uuid IS NULL OR i.creator_id = $6)
AND ($7::uuid IS NULL OR i.project_id = $7)
AND ($8::jsonb IS NULL OR i.metadata @> $8::jsonb)
-- properties_filter is a jsonb array of groups, each group an array of
-- containment patterns (built by parsePropertiesFilterParam): the issue
-- must match at least one pattern from EVERY group (AND of ORs). A pattern
-- of the shape {"__none__": "<definitionId>"} is the "no value" marker and
-- matches when the issue's properties are missing that key. The correlated
-- form skips the GIN index, which is fine here: open_only is an
-- unpaginated workspace scan already narrowed by status.
-- form skips the GIN index, which is fine here: open_only is already an
-- unpaginated workspace scan. The terminal-status predicate intentionally
-- remains a filter rather than a positive index narrowing so unknown legacy
-- status keys stay visible.
AND (
$8::jsonb IS NULL
$9::jsonb IS NULL
OR NOT EXISTS (
SELECT 1
FROM jsonb_array_elements($8::jsonb) AS pf(alternatives)
FROM jsonb_array_elements($9::jsonb) AS pf(alternatives)
WHERE NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(pf.alternatives) AS alt(pattern)
@@ -1234,11 +1248,11 @@ WHERE i.workspace_id = $1
)
)
AND (
$9::uuid IS NULL
$10::uuid IS NULL
OR (i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = $9::uuid
AND a.owner_id = $10::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
@@ -1246,14 +1260,14 @@ WHERE i.workspace_id = $1
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = $9::uuid
AND sm.member_id = $10::uuid
UNION
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = $9::uuid
AND a.owner_id = $10::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
@@ -1262,22 +1276,23 @@ WHERE i.workspace_id = $1
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = $9::uuid
AND a.owner_id = $10::uuid
))
)
ORDER BY i.position ASC, i.created_at DESC
`
type ListOpenIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
AssigneeIds []pgtype.UUID `json:"assignee_ids"`
CreatorID pgtype.UUID `json:"creator_id"`
ProjectID pgtype.UUID `json:"project_id"`
MetadataFilter []byte `json:"metadata_filter"`
PropertiesFilter []byte `json:"properties_filter"`
InvolvesUserID pgtype.UUID `json:"involves_user_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
TerminalStatusKeys []string `json:"terminal_status_keys"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
AssigneeIds []pgtype.UUID `json:"assignee_ids"`
CreatorID pgtype.UUID `json:"creator_id"`
ProjectID pgtype.UUID `json:"project_id"`
MetadataFilter []byte `json:"metadata_filter"`
PropertiesFilter []byte `json:"properties_filter"`
InvolvesUserID pgtype.UUID `json:"involves_user_id"`
}
type ListOpenIssuesRow struct {
@@ -1311,6 +1326,7 @@ type ListOpenIssuesRow struct {
func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) {
rows, err := q.db.Query(ctx, listOpenIssues,
arg.WorkspaceID,
arg.TerminalStatusKeys,
arg.Priority,
arg.AssigneeID,
arg.AssigneeIds,
+11 -4
View File
@@ -126,20 +126,27 @@ func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWor
const getProjectIssueStats = `-- name: GetProjectIssueStats :many
SELECT project_id,
count(*)::bigint AS total_count,
count(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done_count
count(*) FILTER (WHERE status = ANY($1::text[]))::bigint AS done_count
FROM issue
WHERE project_id = ANY($1::uuid[])
WHERE workspace_id = $2::uuid
AND project_id = ANY($3::uuid[])
GROUP BY project_id
`
type GetProjectIssueStatsParams struct {
TerminalStatusKeys []string `json:"terminal_status_keys"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectIds []pgtype.UUID `json:"project_ids"`
}
type GetProjectIssueStatsRow struct {
ProjectID pgtype.UUID `json:"project_id"`
TotalCount int64 `json:"total_count"`
DoneCount int64 `json:"done_count"`
}
func (q *Queries) GetProjectIssueStats(ctx context.Context, projectIds []pgtype.UUID) ([]GetProjectIssueStatsRow, error) {
rows, err := q.db.Query(ctx, getProjectIssueStats, projectIds)
func (q *Queries) GetProjectIssueStats(ctx context.Context, arg GetProjectIssueStatsParams) ([]GetProjectIssueStatsRow, error) {
rows, err := q.db.Query(ctx, getProjectIssueStats, arg.TerminalStatusKeys, arg.WorkspaceID, arg.ProjectIds)
if err != nil {
return nil, err
}
+4 -1
View File
@@ -2050,6 +2050,9 @@ JOIN agent_task_queue failed ON failed.id = recovery.source_task_id
JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id
JOIN issue source_issue ON source_issue.id = source.issue_id
JOIN agent source_agent ON source_agent.id = source.agent_id
LEFT JOIN issue_status source_status
ON source_status.workspace_id = source_issue.workspace_id
AND source_status.key = source_issue.status
WHERE recovery.author_type = 'system'
AND recovery.type = 'progress_update'
AND recovery.source_task_id IS NOT NULL
@@ -2062,7 +2065,7 @@ WHERE recovery.author_type = 'system'
AND source.autopilot_run_id IS NULL
AND source.issue_id IS NOT NULL
AND source.agent_id <> failed.agent_id
AND issue_effective_status(source_issue.workspace_id, source_issue.status) NOT IN ('done', 'cancelled', 'backlog')
AND COALESCE(source_status.category, source_issue.status) NOT IN ('done', 'cancelled', 'backlog')
AND source_agent.archived_at IS NULL
AND source_agent.runtime_id IS NOT NULL
AND source_agent.workspace_id = source_issue.workspace_id
+1 -1
View File
@@ -214,5 +214,5 @@ WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $
AND i.issue_id IN (
SELECT id FROM issue
WHERE workspace_id = $1
AND issue_effective_status(workspace_id, status) IN ('done', 'cancelled')
AND status = ANY(sqlc.arg('terminal_status_keys')::text[])
);
+11 -6
View File
@@ -291,7 +291,8 @@ SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0));
-- name: FindActiveDuplicateIssue :one
SELECT * FROM issue
WHERE workspace_id = $1
AND issue_effective_status(workspace_id, status) NOT IN ('done', 'cancelled')
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid
AND parent_issue_id IS NOT DISTINCT FROM sqlc.arg('parent_issue_id')::uuid
AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = sqlc.arg('normalized_title')
@@ -301,7 +302,8 @@ LIMIT 1;
-- name: FindRecentAutopilotDuplicateIssue :one
SELECT i.* FROM issue i
WHERE i.workspace_id = $1
AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND i.origin_type = 'autopilot'
AND i.origin_id = $2
AND i.project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid
@@ -351,7 +353,8 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
i.revision
FROM issue i
WHERE i.workspace_id = $1
AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')
-- Negate only known terminal keys so an unknown legacy key remains visible.
AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]))
@@ -363,8 +366,10 @@ WHERE i.workspace_id = $1
-- must match at least one pattern from EVERY group (AND of ORs). A pattern
-- of the shape {"__none__": "<definitionId>"} is the "no value" marker and
-- matches when the issue's properties are missing that key. The correlated
-- form skips the GIN index, which is fine here: open_only is an
-- unpaginated workspace scan already narrowed by status.
-- form skips the GIN index, which is fine here: open_only is already an
-- unpaginated workspace scan. The terminal-status predicate intentionally
-- remains a filter rather than a positive index narrowing so unknown legacy
-- status keys stay visible.
AND (
sqlc.narg('properties_filter')::jsonb IS NULL
OR NOT EXISTS (
@@ -510,7 +515,7 @@ GROUP BY assignee_type, assignee_id;
-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done
COUNT(*) FILTER (WHERE status = ANY(sqlc.arg('terminal_status_keys')::text[]))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
+3 -2
View File
@@ -57,7 +57,8 @@ WHERE project_id = $1;
-- name: GetProjectIssueStats :many
SELECT project_id,
count(*)::bigint AS total_count,
count(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done_count
count(*) FILTER (WHERE status = ANY(sqlc.arg('terminal_status_keys')::text[]))::bigint AS done_count
FROM issue
WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[])
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND project_id = ANY(sqlc.arg('project_ids')::uuid[])
GROUP BY project_id;