Files
multica/server/pkg/db/generated/issue.sql.go
Bohan Jiang a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

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

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

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

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

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

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

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

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 00:14:42 +08:00

1272 lines
38 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: issue.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const childIssueProgress = `-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
GROUP BY parent_issue_id
`
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)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChildIssueProgressRow{}
for rows.Next() {
var i ChildIssueProgressRow
if err := rows.Scan(&i.ParentIssueID, &i.Total, &i.Done); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const countCreatedIssueAssignees = `-- name: CountCreatedIssueAssignees :many
SELECT
assignee_type,
assignee_id,
COUNT(*)::bigint as frequency
FROM issue
WHERE workspace_id = $1
AND creator_id = $2
AND creator_type = 'member'
AND assignee_type IS NOT NULL
AND assignee_id IS NOT NULL
GROUP BY assignee_type, assignee_id
`
type CountCreatedIssueAssigneesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatorID pgtype.UUID `json:"creator_id"`
}
type CountCreatedIssueAssigneesRow struct {
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// Count assignees on issues created by a specific user.
func (q *Queries) CountCreatedIssueAssignees(ctx context.Context, arg CountCreatedIssueAssigneesParams) ([]CountCreatedIssueAssigneesRow, error) {
rows, err := q.db.Query(ctx, countCreatedIssueAssignees, arg.WorkspaceID, arg.CreatorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountCreatedIssueAssigneesRow{}
for rows.Next() {
var i CountCreatedIssueAssigneesRow
if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const countIssues = `-- name: CountIssues :one
SELECT count(*) FROM issue i
WHERE i.workspace_id = $1
AND ($2::text IS NULL OR i.status = $2)
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::bool IS NULL OR (i.start_date IS NOT NULL OR i.due_date IS NOT NULL))
AND ($9::jsonb IS NULL OR i.metadata @> $9::jsonb)
AND (
$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 = $10::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
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 = $10::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = $10::uuid
))
)
`
type CountIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
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"`
Scheduled pgtype.Bool `json:"scheduled"`
MetadataFilter []byte `json:"metadata_filter"`
InvolvesUserID pgtype.UUID `json:"involves_user_id"`
}
// See ListIssues for the semantics of involves_user_id.
func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssues,
arg.WorkspaceID,
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.Scheduled,
arg.MetadataFilter,
arg.InvolvesUserID,
)
var count int64
err := row.Scan(&count)
return count, err
}
const createIssue = `-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, start_date, due_date, number, project_id,
stage
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16
) RETURNING 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
`
type CreateIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
Stage pgtype.Int4 `json:"stage"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, createIssue,
arg.WorkspaceID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.AssigneeType,
arg.AssigneeID,
arg.CreatorType,
arg.CreatorID,
arg.ParentIssueID,
arg.Position,
arg.StartDate,
arg.DueDate,
arg.Number,
arg.ProjectID,
arg.Stage,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const createIssueWithOrigin = `-- name: CreateIssueWithOrigin :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, start_date, due_date, number, project_id,
origin_type, origin_id, stage
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18
) RETURNING 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
`
type CreateIssueWithOriginParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
OriginType pgtype.Text `json:"origin_type"`
OriginID pgtype.UUID `json:"origin_id"`
Stage pgtype.Int4 `json:"stage"`
}
func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWithOriginParams) (Issue, error) {
row := q.db.QueryRow(ctx, createIssueWithOrigin,
arg.WorkspaceID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.AssigneeType,
arg.AssigneeID,
arg.CreatorType,
arg.CreatorID,
arg.ParentIssueID,
arg.Position,
arg.StartDate,
arg.DueDate,
arg.Number,
arg.ProjectID,
arg.OriginType,
arg.OriginID,
arg.Stage,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const deleteIssue = `-- name: DeleteIssue :exec
DELETE FROM issue WHERE id = $1 AND workspace_id = $2
`
type DeleteIssueParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Defense-in-depth: the workspace_id predicate makes the tenant invariant a
// SQL-layer guarantee rather than a handler-layer one. Handler loaders
// (loadIssueForUser / GetIssueInWorkspace) already enforce membership today,
// but a future loader bypass or a new caller skipping the loader would be
// silently catastrophic without this guard. See incident #1661.
func (q *Queries) DeleteIssue(ctx context.Context, arg DeleteIssueParams) error {
_, err := q.db.Exec(ctx, deleteIssue, arg.ID, arg.WorkspaceID)
return err
}
const deleteIssueMetadataKey = `-- name: DeleteIssueMetadataKey :one
UPDATE issue SET
metadata = metadata - $1::text,
updated_at = now()
WHERE id = $2 AND workspace_id = $3
RETURNING 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
`
type DeleteIssueMetadataKeyParams struct {
Key string `json:"key"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Atomically removes a single key from the issue's metadata JSONB.
// Deleting a missing key is a no-op (still returns the row).
func (q *Queries) DeleteIssueMetadataKey(ctx context.Context, arg DeleteIssueMetadataKeyParams) (Issue, error) {
row := q.db.QueryRow(ctx, deleteIssueMetadataKey, arg.Key, arg.ID, arg.WorkspaceID)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
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 FROM issue
WHERE workspace_id = $1
AND 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
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"`
}
func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDuplicateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, findActiveDuplicateIssue,
arg.WorkspaceID,
arg.ProjectID,
arg.ParentIssueID,
arg.NormalizedTitle,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const getIssue = `-- name: GetIssue :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 FROM issue
WHERE id = $1
`
func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
row := q.db.QueryRow(ctx, getIssue, id)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const getIssueByNumber = `-- name: GetIssueByNumber :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 FROM issue
WHERE workspace_id = $1 AND number = $2
`
type GetIssueByNumberParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Number int32 `json:"number"`
}
func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberParams) (Issue, error) {
row := q.db.QueryRow(ctx, getIssueByNumber, arg.WorkspaceID, arg.Number)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const getIssueByOrigin = `-- name: GetIssueByOrigin :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 FROM issue
WHERE workspace_id = $1
AND origin_type = $2
AND origin_id = $3
LIMIT 1
`
type GetIssueByOriginParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
OriginType pgtype.Text `json:"origin_type"`
OriginID pgtype.UUID `json:"origin_id"`
}
// Finds the issue stamped with a specific (origin_type, origin_id) pair.
// Used by quick-create completion to deterministically locate the issue
// produced by a given agent_task_queue.id — robust against concurrent
// issue creates by the same agent (assignment task + quick-create both
// running with max_concurrent_tasks > 1).
func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginParams) (Issue, error) {
row := q.db.QueryRow(ctx, getIssueByOrigin, arg.WorkspaceID, arg.OriginType, arg.OriginID)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const getIssueInWorkspace = `-- name: GetIssueInWorkspace :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 FROM issue
WHERE id = $1 AND workspace_id = $2
`
type GetIssueInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspaceParams) (Issue, error) {
row := q.db.QueryRow(ctx, getIssueInWorkspace, arg.ID, arg.WorkspaceID)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const listChildIssues = `-- name: ListChildIssues :many
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 FROM issue
WHERE parent_issue_id = $1
ORDER BY position ASC, created_at DESC
`
func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID) ([]Issue, error) {
rows, err := q.db.Query(ctx, listChildIssues, parentIssueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Issue{}
for rows.Next() {
var i Issue
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listChildrenByParents = `-- name: ListChildrenByParents :many
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 FROM issue
WHERE workspace_id = $1
AND parent_issue_id = ANY($2::uuid[])
ORDER BY parent_issue_id, position ASC, created_at DESC
`
type ListChildrenByParentsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentIds []pgtype.UUID `json:"parent_ids"`
}
// Batched variant of ListChildIssues: returns all children for the given
// parent set in one round trip. Used by Swimlane to avoid an N+1 fan-out
// (one request per visible parent lane). Result is grouped client-side by
// parent_issue_id; the workspace filter is also enforced so callers can't
// enumerate children of parents in workspaces they don't belong to.
func (q *Queries) ListChildrenByParents(ctx context.Context, arg ListChildrenByParentsParams) ([]Issue, error) {
rows, err := q.db.Query(ctx, listChildrenByParents, arg.WorkspaceID, arg.ParentIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Issue{}
for rows.Next() {
var i Issue
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIssues = `-- name: ListIssues :many
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.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage
FROM issue i
WHERE i.workspace_id = $1
AND ($4::text IS NULL OR i.status = $4)
AND ($5::text IS NULL OR i.priority = $5)
AND ($6::uuid IS NULL OR i.assignee_id = $6)
AND ($7::uuid[] IS NULL OR i.assignee_id = ANY($7::uuid[]))
AND ($8::uuid IS NULL OR i.creator_id = $8)
AND ($9::uuid IS NULL OR i.project_id = $9)
AND ($10::bool IS NULL OR (i.start_date IS NOT NULL OR i.due_date IS NOT NULL))
AND ($11::jsonb IS NULL OR i.metadata @> $11::jsonb)
AND (
$12::uuid IS NULL
-- (1) assignee is an agent owned by the user
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 = $12::uuid
))
-- (2)(3)(4) assignee is a squad related to the user — three relations
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
-- (2) the user is a human member of the squad
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = $12::uuid
UNION
-- (3) the squad's canonical leader is an agent owned by the user.
-- We read squad.leader_id directly rather than relying on a
-- squad_member row, because the leader copy in squad_member is
-- best-effort (see squad.go AddSquadMember error handling).
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 = $12::uuid
UNION
-- (4) the squad has an agent member owned by the user
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = $12::uuid
))
)
ORDER BY i.position ASC, i.created_at DESC
LIMIT $2 OFFSET $3
`
type ListIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
Status pgtype.Text `json:"status"`
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"`
Scheduled pgtype.Bool `json:"scheduled"`
MetadataFilter []byte `json:"metadata_filter"`
InvolvesUserID pgtype.UUID `json:"involves_user_id"`
}
type ListIssuesRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
Metadata []byte `json:"metadata"`
Stage pgtype.Int4 `json:"stage"`
}
// involves_user_id widens the assignee filter to surface issues where the user
// is *indirectly* the assignee — via an owned agent or a squad they belong to /
// lead / have an agent inside. The semantics intentionally exclude direct
// member assignment (`assignee_type='member' AND assignee_id=involves_user_id`)
// because that is already the meaning of the `assignee_id` filter (tab 1
// "Assigned to me"), and the two filters must produce disjoint result sets.
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListIssuesRow, error) {
rows, err := q.db.Query(ctx, listIssues,
arg.WorkspaceID,
arg.Limit,
arg.Offset,
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.Scheduled,
arg.MetadataFilter,
arg.InvolvesUserID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIssuesRow{}
for rows.Next() {
var i ListIssuesRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.Position,
&i.StartDate,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.Metadata,
&i.Stage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOpenIssues = `-- name: ListOpenIssues :many
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.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage
FROM issue i
WHERE i.workspace_id = $1
AND 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)
AND (
$8::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 = $8::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = $8::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 = $8::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = $8::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"`
InvolvesUserID pgtype.UUID `json:"involves_user_id"`
}
type ListOpenIssuesRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
Position float64 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
Metadata []byte `json:"metadata"`
Stage pgtype.Int4 `json:"stage"`
}
// See ListIssues for the semantics of involves_user_id (mirrors the 4-branch
// filter; member-direct assignment is intentionally excluded).
func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) {
rows, err := q.db.Query(ctx, listOpenIssues,
arg.WorkspaceID,
arg.Priority,
arg.AssigneeID,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.MetadataFilter,
arg.InvolvesUserID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListOpenIssuesRow{}
for rows.Next() {
var i ListOpenIssuesRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.Position,
&i.StartDate,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.Metadata,
&i.Stage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockIssueDuplicateKey = `-- name: LockIssueDuplicateKey :exec
SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))
`
func (q *Queries) LockIssueDuplicateKey(ctx context.Context, dollar_1 string) error {
_, err := q.db.Exec(ctx, lockIssueDuplicateKey, dollar_1)
return err
}
const markIssueFirstExecuted = `-- name: MarkIssueFirstExecuted :one
UPDATE issue
SET first_executed_at = now()
WHERE id = $1 AND first_executed_at IS NULL
RETURNING id, workspace_id, creator_type, creator_id, first_executed_at
`
type MarkIssueFirstExecutedRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
FirstExecutedAt pgtype.Timestamptz `json:"first_executed_at"`
}
// Flips first_executed_at from NULL to now() atomically. Returns the row if
// this was the first time the issue was executed; no rows otherwise. The
// analytics issue_executed event fires exactly when this returns a row —
// retries and re-assignments hit the WHERE clause and no-op.
func (q *Queries) MarkIssueFirstExecuted(ctx context.Context, id pgtype.UUID) (MarkIssueFirstExecutedRow, error) {
row := q.db.QueryRow(ctx, markIssueFirstExecuted, id)
var i MarkIssueFirstExecutedRow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.CreatorType,
&i.CreatorID,
&i.FirstExecutedAt,
)
return i, err
}
const setIssueMetadataKey = `-- name: SetIssueMetadataKey :one
UPDATE issue SET
metadata = jsonb_set(metadata, ARRAY[$1::text], $2::jsonb),
updated_at = now()
WHERE id = $3 AND workspace_id = $4
RETURNING 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
`
type SetIssueMetadataKeyParams struct {
Key string `json:"key"`
Value []byte `json:"value"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// SearchIssues: moved to handler (dynamic SQL for multi-word search support).
// Atomically sets a single key in the issue's metadata JSONB. The
// workspace_id filter is the authorization gate — handler resolves the
// issue first so this is also the tenant check.
func (q *Queries) SetIssueMetadataKey(ctx context.Context, arg SetIssueMetadataKeyParams) (Issue, error) {
row := q.db.QueryRow(ctx, setIssueMetadataKey,
arg.Key,
arg.Value,
arg.ID,
arg.WorkspaceID,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const updateIssue = `-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE($2, title),
description = COALESCE($3, description),
status = COALESCE($4, status),
priority = COALESCE($5, priority),
assignee_type = $6,
assignee_id = $7,
position = COALESCE($8, position),
start_date = $9,
due_date = $10,
parent_issue_id = $11,
project_id = $12,
stage = $13,
updated_at = now()
WHERE id = $1
RETURNING 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
`
type UpdateIssueParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
ProjectID pgtype.UUID `json:"project_id"`
Stage pgtype.Int4 `json:"stage"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, updateIssue,
arg.ID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.AssigneeType,
arg.AssigneeID,
arg.Position,
arg.StartDate,
arg.DueDate,
arg.ParentIssueID,
arg.ProjectID,
arg.Stage,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}
const updateIssueStatus = `-- name: UpdateIssueStatus :one
UPDATE issue SET
status = $2,
updated_at = now()
WHERE id = $1 AND workspace_id = $3
RETURNING 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
`
type UpdateIssueStatusParams struct {
ID pgtype.UUID `json:"id"`
Status string `json:"status"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Workspace_id in the WHERE clause is a SQL-layer tenant guard; see DeleteIssue.
func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusParams) (Issue, error) {
row := q.db.QueryRow(ctx, updateIssueStatus, arg.ID, arg.Status, arg.WorkspaceID)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
)
return i, err
}