mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-29 16:59:28 +08:00
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:
co-authored by
multica-agent
Sol-Boy
parent
1dd6b9ecdb
commit
db86c75e12
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[])
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user