Files
multica/server/pkg/db/generated/issue.sql.go
T
7ebe0bf58d MUL-7213: feat(server): Triage has no executor (#8373)
* feat(server): start no run for an issue in triage (MUL-7213)

An issue waiting in Triage produces no execution run, and the triage run
itself leaves no trace an execution run can inherit.

The rule cannot be enforced at the call sites. Five paths enqueue without
looking at status at all (comment and mention dispatch, quick action,
manual rerun, auto-retry), and dispatchIssueRun discards the enqueue error
entirely, so a missed call site would fail silently. So the check sits at
the queue door — immediately before every INSERT of an issue-linked
agent_task_queue row — and the upstream short-circuits exist only so that
previews and dispatch reasons stay honest about what the door will do.

guardIssueNotInTriage covers all five insert sites: both enqueue funnels,
FailTask's in-transaction retry, the orphan sweeper's retry, and the
delegated-failure recovery dispatch. Two future paths are meant to be
exempt and both are Triage's own — the triage run, and accept, which
leaves Triage in the same transaction that enqueues the execution run.

Upstream, WillEnqueueRun, maybeEnqueueOnAssign, the two
shouldEnqueue*OnAssign gates and the comment trigger router all refuse
Triage; rerun and quick action refuse with 403 issue_in_triage, a new
dispatch reason code. WillEnqueueRun also treats leaving Triage as a
creation rather than a status change: accept is when the issue becomes
work, and the backlog-to-elsewhere rule would refuse it outright.

Two narrower rules follow. retryEligible excludes triage runs for the
reason it excludes autopilot runs: re-triaging reads the entry as it
stands now, so replaying a failed attempt is never the right recovery.
And GetLastTaskSession excludes them from all three CTEs, so the first
execution run after accept does not resume the conversation in which the
agent was still deciding whether the entry was worth keeping.

Refusals are deliberately quiet where a caller cannot act on them. The
FailTask guard never returns — that transaction also carries the parent's
failed status, and losing it would leave the task stuck in 'running'.

Verified: internal/handler, internal/service and cmd/server pass; every
new test was confirmed to fail with the change neutralized.

Co-authored-by: multica-agent <[email protected]>

* fix(server): close two triage session bypasses around rerun (MUL-7213)

Review found that session isolation had two holes the query-level
exclusion could not reach, and that the two quietest queue-door paths had
no direct coverage.

A manual rerun resolves its session from the task the user named, via
rerun_of_task_id, not from GetLastTaskSession — so after accept, naming
the historical triage task reran the triager and resumed the triage
conversation, walking straight around the exclusion. RerunIssue now
refuses a triage source: redoing triage is re-triage, a different action
on a different status. rerunSourceMatchesTaskScope refuses it too, which
is what still holds for a rerun row an older server wrote mid-deploy.

GetLatestTaskRolloutMissing reports only whether GetLastTaskSession fell
back, so the two have to exclude the same rows. It did not, so a terminal
triage run with session_rollout_missing told the first execution run after
accept that it had lost the previous turn's context — about a turn it was
never entitled to.

Tests now cover the two doors that refuse silently: FailTask's
in-transaction retry, which must leave the parent failed and add no child,
and dispatchDelegatedFailureRecovery, which builds its coordinator task
with a direct CreateAgentTask call. Both assert against a positive control
so a zero cannot come from an inert fixture; the recovery test also shows
the obligation is deferred rather than dropped — the same pending comment
wakes the coordinator once the issue is accepted.

Verified: internal/handler, internal/service and cmd/server pass; all five
new tests were confirmed to fail with the fixes neutralized.

Co-authored-by: multica-agent <[email protected]>

* refactor(server): let a named agent talk on a Triage issue (MUL-7213)

The rule shipped as "an issue in Triage starts no run", which is a
mechanical restatement of the product red line rather than the line
itself, and it was too wide: @-mentioning an agent to discuss a Triage
entry was refused along with everything else.

What Triage actually lacks is an EXECUTOR. Nothing may derive one from the
issue — the assignee, a prefilled owner, a squad leader, an automatic
retry — because that is exactly what the triager is still deciding, and
because prefilling an owner is how a run starts today, the triager would
otherwise dispatch the very owner it was only proposing. A person naming
an agent by hand is a different act: no triager action produces it and the
cost is one the person chose. That is the same shape as backlog, which
parks assignment but lets comments through, one notch tighter because in
Triage the assignee is only a proposal.

So the queue door now judges the run's origin rather than the funnel it
came through. OriginDerived is the zero value, so a caller that says
nothing still gets the strict answer and a new enqueue path cannot leak in
by omission; the two mixed wrappers take it as a parameter, which made the
compiler enumerate every call site.

Comment routing loses its blanket short-circuit and instead refuses the
two routes that derive an executor — the assignee fallback and the squad
leader fallback. Explicit mentions, replies to an agent, and thread
continuation route as they do anywhere else. Rerun now follows its source:
a discussion run may be repeated, naming no source means "run the assignee
again" and is refused, and the triage run itself keeps its own reason.

Assignment, quick action, auto-retry and all three session isolations are
unchanged. The delegated-failure door is gone because main's #8406 gate
subsumes it: Triage is its own lifecycle category, so it is neither
unstarted nor started and both that gate and the pending-recovery query
already exclude it. A test pins that.

Verified: internal/handler, internal/service and cmd/server pass; both
halves of the new rule were confirmed to fail when neutralized in place.

Co-authored-by: multica-agent <[email protected]>

* refactor(server): hold Triage in its own column, not in status (MUL-7213)

`status` answers "how far along is this work". Triage answers "is this
work at all". Holding the second question inside the first field makes
every reader of `status` re-answer it, which is where the scattered
`<> 'triage'` checks came from — main carried 9 of them and this branch
had grown to 23. A column of its own asks it once.

issue.triage_state is NULL for an ordinary issue and `pending` for one
waiting in Triage. Adding a nullable column with no default is a
catalog-only change, so no table rewrite and no backfill; the queue reads
a partial index sized to itself rather than to the issue table.

What follows from the move:

- issuestatus goes back to 7 keys and 4 categories. Triage is no longer a
  category, a behavior, or something ExpandCategories can name, so
  CategoryForBehavior, BehaviorsForCategory, ParseCategory and
  ExpandCategories lose their special cases, and the dead board-order arm
  and status label go with them.
- WillEnqueueRun loses `acceptedFromTriage`. Accept clears a column and
  then takes the ordinary create / assign path, so there is no "was in
  triage" transition left for the predicate to recognise.
- The write lock disappears rather than narrowing. Status, project and
  parent on a Triage entry are the triager's proposal and accept is what
  confirms them, so ordinary writes reach them; the marker itself is not
  a field of the issue API, so there is nothing left to lock.
- The duplicate guards carry `triage_state IS NULL` instead of a status
  key, and the delegated-failure recovery predicate gains the same, in
  both its SQL and its Go mirror.

What does NOT follow, deliberately: the reserved NAME stays. Migration
475's CHECK is deployed and 476's renames are irreversible, so `triage`
remains unavailable as a custom status key and Resolve keeps refusing it
— one reserved word rather than five special cases. Read-path exclusion
is a separate sub-issue and is not attempted here.

Also from review: the quick-action refusal is now documented as a
first-phase product choice rather than a consequence of the rule (its
target is configured, not derived), and ErrIssueInTriage no longer claims
that nothing runs.

Verified: handler, service, issuestatus, cmd/migrate and cmd/server pass
on a database migrated from 001; every column read was confirmed to turn
its own tests red when neutralized. cmd/migrate's concurrent-index
convention test caught a missing cleanup hook for the new index.

Co-authored-by: multica-agent <[email protected]>

* fix(server): hold the parent of a Triage entry (MUL-7213)

Removing the write lock went one field too far. Status, project, assignee
and the rest on a Triage entry are the triager's proposal and accept
confirms them, so those writes belong. The parent is not a proposal:

- the routing panel has no parent field. "Merge into an existing issue"
  links rather than re-parents, so a Triage entry hanging off a parent is
  a half-state the product never asks for;
- a child in Triage carries a proposed status, so it is never terminal.
  stageBarrierClosed would wait on it forever — holding the whole sibling
  set, or one stage's frontier — and ChildIssueProgress would count it in
  the denominator. Both reach it through parent_issue_id, so refusing the
  write is what keeps them out of reach. That is behavior, not display,
  and it is why this one is worth a lock rather than a read-path filter.

UpdateIssue and batch refuse a request that carries parent_issue_id for
an issue in Triage, with or without a value: null clears, which is
equally a parent decision the triager does not get to make. The batch
asks one count rather than listing statuses, since "any" is all it needs.

Verified: handler, service, issuestatus, issueguard, cmd/migrate and
cmd/server pass; both new tests were confirmed to fail with the guard
neutralized.

Co-authored-by: multica-agent <[email protected]>

* fix(server): drop the triage case from the handoff predicate (MUL-7213)

MUL-7379 (#8435) landed on main while this branch was open and added
issueStatusIsHandoff with a `triage` case, resting on Triage resolving to
a category of its own without a catalog read. Once Triage stops being a
status that resolution is gone, so the call errors on a value no issue can
hold any more.

The case is removed rather than repaired: a Triage entry carries whatever
status the triager has proposed and is answered on that. Suppressing
notifications for Triage entries is a different rule and belongs to
MUL-7219 — the status model was giving it incidentally, and that was never
the mechanism the design asks for.

Verified on a database migrated from 001: cmd/server, internal/handler,
internal/service, internal/issuestatus and cmd/migrate pass.

Co-authored-by: multica-agent <[email protected]>

* chore(db): renumber the triage migrations past the telemetry batch (MUL-7213)

MUL-7236 (#8252) took 479-482 on main while this branch was open, so both
triage migrations collided. 483 adds issue.triage_state, 484 builds its
partial index; the concurrent-index cleanup hook moves with it.

Verified on a database migrated from 001 with the new numbering.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-15 15:34:21 +08:00

1993 lines
67 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 = 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, arg ChildIssueProgressParams) ([]ChildIssueProgressRow, error) {
rows, err := q.db.Query(ctx, childIssueProgress, arg.WorkspaceID, arg.TerminalStatusKeys)
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 countIssuesInTriage = `-- name: CountIssuesInTriage :one
SELECT count(*) FROM issue
WHERE workspace_id = $1
AND id = ANY($2::uuid[])
AND triage_state IS NOT NULL
`
type CountIssuesInTriageParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueIds []pgtype.UUID `json:"issue_ids"`
}
// How many of these issues are in Triage. The batch parent-write guard only
// needs "any", and a count keeps the check one round trip regardless of size.
func (q *Queries) CountIssuesInTriage(ctx context.Context, arg CountIssuesInTriageParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssuesInTriage, arg.WorkspaceID, arg.IssueIds)
var count int64
err := row.Scan(&count)
return count, err
}
const countIssuesUpTo = `-- name: CountIssuesUpTo :one
SELECT COUNT(*)::bigint
FROM (
SELECT 1
FROM issue
WHERE workspace_id = $1
LIMIT $2::bigint
) bounded_issues
`
type CountIssuesUpToParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int64 `json:"limit"`
}
// Bounded count for issue-limit admission and display. Callers pass only the
// threshold needed for their decision, avoiding a full scan in an oversized
// workspace.
func (q *Queries) CountIssuesUpTo(ctx context.Context, arg CountIssuesUpToParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssuesUpTo, arg.WorkspaceID, arg.Limit)
var column_1 int64
err := row.Scan(&column_1)
return column_1, 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, last_activity_at, id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, now(), COALESCE($17::uuid, gen_random_uuid())
) 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, properties, revision, last_activity_at, triage_state
`
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"`
ID pgtype.UUID `json:"id"`
}
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,
arg.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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
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, last_activity_at, id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, now(), COALESCE($19::uuid, gen_random_uuid())
) 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, properties, revision, last_activity_at, triage_state
`
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"`
ID pgtype.UUID `json:"id"`
}
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,
arg.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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
const deleteIssue = `-- name: DeleteIssue :exec
WITH target AS (
SELECT issue.id FROM issue WHERE issue.id = $1 AND issue.workspace_id = $2
),
cleared_vcs_pr_links AS (
DELETE FROM issue_vcs_pull_request WHERE issue_id IN (SELECT target.id FROM target)
)
DELETE FROM issue WHERE issue.id IN (SELECT target.id FROM target)
`
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.
//
// issue_vcs_pull_request (migration 213) has no FK to issue, so the link rows
// are not cascaded away. Sweep them here so they go atomically with the issue.
// The mirrored PR rows themselves belong to the connection, not the issue, so
// they persist (matching the GitHub link behaviour).
//
// The sweep MUST route through the same workspace-checked target as the issue
// delete: deleting links by bare issue_id would drop another tenant's link rows
// when a caller passes a foreign issue_id with its own workspace_id (the issue
// itself is correctly untouched, but the links are already gone) — the exact
// cross-tenant leak the #1661 guard above exists to prevent.
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,
revision = revision + 1,
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now()),
updated_at = now()
WHERE id = $2 AND workspace_id = $3
AND metadata ? $1::text
RETURNING id, workspace_id, metadata, revision
`
type DeleteIssueMetadataKeyParams struct {
Key string `json:"key"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type DeleteIssueMetadataKeyRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Metadata []byte `json:"metadata"`
Revision int64 `json:"revision"`
}
// Atomically removes a single key from the issue's metadata JSONB.
// Deleting a missing key is a no-op (returns no rows).
func (q *Queries) DeleteIssueMetadataKey(ctx context.Context, arg DeleteIssueMetadataKeyParams) (DeleteIssueMetadataKeyRow, error) {
row := q.db.QueryRow(ctx, deleteIssueMetadataKey, arg.Key, arg.ID, arg.WorkspaceID)
var i DeleteIssueMetadataKeyRow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Metadata,
&i.Revision,
)
return i, err
}
const detachDirectChildIssues = `-- name: DetachDirectChildIssues :many
UPDATE issue
SET parent_issue_id = NULL,
stage = NULL,
revision = revision + 1,
updated_at = now(),
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now())
WHERE workspace_id = $1
AND parent_issue_id = $2
AND NOT COALESCE(id = ANY($3::uuid[]), false)
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, properties, revision, last_activity_at, triage_state
`
type DetachDirectChildIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
ExcludedIssueIds []pgtype.UUID `json:"excluded_issue_ids"`
}
func (q *Queries) DetachDirectChildIssues(ctx context.Context, arg DetachDirectChildIssuesParams) ([]Issue, error) {
rows, err := q.db.Query(ctx, detachDirectChildIssues, arg.WorkspaceID, arg.ParentIssueID, arg.ExcludedIssueIds)
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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, triage_state FROM issue
WHERE workspace_id = $1
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (status = ANY($2::text[]))
-- An entry waiting in Triage has not been taken on, so it never blocks
-- someone filing the same work; a duplicate there is resolved by merging
-- it out of Triage (MUL-7189 §2.6).
AND triage_state IS NULL
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"`
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,
)
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
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, i.triage_state FROM issue i
WHERE i.workspace_id = $1
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (i.status = ANY($3::text[]))
-- An entry waiting in Triage has not been taken on, so it never blocks
-- someone filing the same work; a duplicate there is resolved by merging
-- it out of Triage (MUL-7189 §2.6).
AND i.triage_state IS NULL
AND i.origin_type = 'autopilot'
AND i.origin_id = $2
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
WHERE r.issue_id = i.id
AND r.autopilot_id = i.origin_id
AND r.status IN ('issue_created', 'running', 'completed')
)
ORDER BY i.created_at ASC
LIMIT 1
`
type FindRecentAutopilotDuplicateIssueParams struct {
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,
)
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
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, properties, revision, last_activity_at, triage_state 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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
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, properties, revision, last_activity_at, triage_state 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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
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, properties, revision, last_activity_at, triage_state 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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
const getIssueGCStatus = `-- name: GetIssueGCStatus :one
SELECT workspace_id, status, updated_at
FROM issue
WHERE id = $1
`
type GetIssueGCStatusRow struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status string `json:"status"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetIssueGCStatus(ctx context.Context, id pgtype.UUID) (GetIssueGCStatusRow, error) {
row := q.db.QueryRow(ctx, getIssueGCStatus, id)
var i GetIssueGCStatusRow
err := row.Scan(&i.WorkspaceID, &i.Status, &i.UpdatedAt)
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, properties, revision, last_activity_at, triage_state 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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
const getIssueMetadataInWorkspace = `-- name: GetIssueMetadataInWorkspace :one
SELECT metadata, revision FROM issue
WHERE id = $1 AND workspace_id = $2
`
type GetIssueMetadataInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type GetIssueMetadataInWorkspaceRow struct {
Metadata []byte `json:"metadata"`
Revision int64 `json:"revision"`
}
// Reloads the committed metadata snapshot after a conditional mutation
// returns no rows, without fetching the rest of the issue payload.
func (q *Queries) GetIssueMetadataInWorkspace(ctx context.Context, arg GetIssueMetadataInWorkspaceParams) (GetIssueMetadataInWorkspaceRow, error) {
row := q.db.QueryRow(ctx, getIssueMetadataInWorkspace, arg.ID, arg.WorkspaceID)
var i GetIssueMetadataInWorkspaceRow
err := row.Scan(&i.Metadata, &i.Revision)
return i, err
}
const getIssueTriageState = `-- name: GetIssueTriageState :one
SELECT triage_state FROM issue WHERE id = $1
`
// Answers "is this issue in Triage" for the queue door, which runs immediately
// before an INSERT and must see the status its own transaction wrote. NULL is
// an ordinary issue.
func (q *Queries) GetIssueTriageState(ctx context.Context, id pgtype.UUID) (pgtype.Text, error) {
row := q.db.QueryRow(ctx, getIssueTriageState, id)
var triage_state pgtype.Text
err := row.Scan(&triage_state)
return triage_state, 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, properties, revision, last_activity_at, triage_state FROM issue
WHERE parent_issue_id = $1
ORDER BY number ASC
`
// Order by number ASC so sub-issues display in stable creation order
// (oldest first), matching how a parent's plan reads top-to-bottom. The
// position column is computed per-(workspace, status) by NextTopPosition,
// not relative to siblings, so ordering by it interleaves children
// unpredictably across batches and statuses; number is a per-workspace
// monotonic counter and is sibling-stable.
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
); 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, properties, revision, last_activity_at, triage_state FROM issue
WHERE workspace_id = $1
AND parent_issue_id = ANY($2::uuid[])
ORDER BY parent_issue_id, number ASC
`
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.
// Within each parent, order by number ASC for the same sibling-stable
// creation order as ListChildIssues.
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIssueGCStatuses = `-- name: ListIssueGCStatuses :many
SELECT id, status, updated_at
FROM issue
WHERE workspace_id = $1
AND id = ANY($2::uuid[])
`
type ListIssueGCStatusesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueIds []pgtype.UUID `json:"issue_ids"`
}
type ListIssueGCStatusesRow struct {
ID pgtype.UUID `json:"id"`
Status string `json:"status"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListIssueGCStatuses(ctx context.Context, arg ListIssueGCStatusesParams) ([]ListIssueGCStatusesRow, error) {
rows, err := q.db.Query(ctx, listIssueGCStatuses, arg.WorkspaceID, arg.IssueIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIssueGCStatusesRow{}
for rows.Next() {
var i ListIssueGCStatusesRow
if err := rows.Scan(&i.ID, &i.Status, &i.UpdatedAt); 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.last_activity_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
i.revision
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"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
Metadata []byte `json:"metadata"`
Stage pgtype.Int4 `json:"stage"`
Properties []byte `json:"properties"`
Revision int64 `json:"revision"`
}
// 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.LastActivityAt,
&i.Number,
&i.ProjectID,
&i.Metadata,
&i.Stage,
&i.Properties,
&i.Revision,
); 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.last_activity_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
i.revision
FROM issue i
WHERE i.workspace_id = $1
-- 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
-- patterns (built by parsePropertiesFilterParam): the issue must match at
-- least one pattern from EVERY group (AND of ORs). Three pattern shapes:
-- {"__none__": "<definitionId>"} — "no value" marker;
-- {"<definitionId>": <value>, ...} — containment;
-- {"__op__": "<op>", "def": "<id>", "value": "<v>"} — scalar operator
-- (contains / gt / gte / lt / lte / before / after), where the contains
-- value arrives already ILIKE-escaped and the op was validated in Go, so
-- the per-op branches below only ever see legal values. A contains pattern
-- additionally carries "prefilter" when its needle survives jsonb text
-- serialization intact; the clause it enables is redundant by construction
-- (prefilterableContainsNeedle in property.go) and stays here so both renderings
-- of the filter keep matching row for row. The correlated form skips the GIN
-- index, which is fine here: open_only is 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 (
$9::jsonb IS NULL
OR NOT EXISTS (
SELECT 1
FROM jsonb_array_elements($9::jsonb) AS pf(alternatives)
WHERE NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(pf.alternatives) AS alt(pattern)
WHERE (alt.pattern ? '__none__' AND NOT (i.properties ? (alt.pattern ->> '__none__')))
OR (NOT (alt.pattern ? '__none__') AND NOT (alt.pattern ? '__op__')
AND i.properties @> alt.pattern)
OR (alt.pattern ? '__op__'
AND i.properties ->> (alt.pattern ->> 'def') IS NOT NULL
AND (
(alt.pattern ->> '__op__' = 'contains'
AND (NOT (alt.pattern ? 'prefilter')
OR LOWER(i.properties::text) LIKE LOWER('%' || (alt.pattern ->> 'prefilter') || '%'))
AND jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'string'
AND (i.properties ->> (alt.pattern ->> 'def')) ILIKE '%' || (alt.pattern ->> 'value') || '%')
OR (alt.pattern ->> '__op__' = 'gt'
AND CASE WHEN jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'number'
THEN (i.properties ->> (alt.pattern ->> 'def'))::numeric END > (alt.pattern ->> 'value')::numeric)
OR (alt.pattern ->> '__op__' = 'gte'
AND CASE WHEN jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'number'
THEN (i.properties ->> (alt.pattern ->> 'def'))::numeric END >= (alt.pattern ->> 'value')::numeric)
OR (alt.pattern ->> '__op__' = 'lt'
AND CASE WHEN jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'number'
THEN (i.properties ->> (alt.pattern ->> 'def'))::numeric END < (alt.pattern ->> 'value')::numeric)
OR (alt.pattern ->> '__op__' = 'lte'
AND CASE WHEN jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'number'
THEN (i.properties ->> (alt.pattern ->> 'def'))::numeric END <= (alt.pattern ->> 'value')::numeric)
OR (alt.pattern ->> '__op__' = 'before'
AND jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'string'
AND i.properties ->> (alt.pattern ->> 'def') < (alt.pattern ->> 'value'))
OR (alt.pattern ->> '__op__' = 'after'
AND jsonb_typeof(i.properties -> (alt.pattern ->> 'def')) = 'string'
AND i.properties ->> (alt.pattern ->> 'def') > (alt.pattern ->> 'value'))
))
)
)
)
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
))
)
ORDER BY i.position ASC, i.created_at DESC
`
type ListOpenIssuesParams struct {
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 {
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"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
Metadata []byte `json:"metadata"`
Stage pgtype.Int4 `json:"stage"`
Properties []byte `json:"properties"`
Revision int64 `json:"revision"`
}
// 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.TerminalStatusKeys,
arg.Priority,
arg.AssigneeID,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.MetadataFilter,
arg.PropertiesFilter,
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.LastActivityAt,
&i.Number,
&i.ProjectID,
&i.Metadata,
&i.Stage,
&i.Properties,
&i.Revision,
); 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 lockIssueForAttachmentWrite = `-- name: LockIssueForAttachmentWrite :one
SELECT id FROM issue
WHERE id = $1 AND workspace_id = $2
FOR NO KEY UPDATE
`
type LockIssueForAttachmentWriteParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Owner-first guard for a write to one of an issue's attachments: take the
// issue before the attachment row, so a writer that reaches the same row
// through the issue — teardown's issue_id cascade, or the revision bump this
// write itself performs — either waits for this transaction or is waited on,
// never both.
//
// FOR NO KEY UPDATE, the mode of that revision bump, is the weakest mode that
// actually serializes issue writers. FOR KEY SHARE is NOT enough: it is
// compatible with FOR NO KEY UPDATE (see LockLiveComment), so a concurrent
// CreateComment would take the issue anyway, wait on the attachment this
// transaction holds, and deadlock with its bump.
func (q *Queries) LockIssueForAttachmentWrite(ctx context.Context, arg LockIssueForAttachmentWriteParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockIssueForAttachmentWrite, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const lockIssueForChannelMediaBind = `-- name: LockIssueForChannelMediaBind :one
SELECT id FROM issue
WHERE id = $1 AND workspace_id = $2
FOR KEY SHARE
`
type LockIssueForChannelMediaBindParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Channel media resolves after /issue creation. Hold a key-share lock while
// the attachment row is written so a concurrent issue delete cannot land
// between the workspace-scoped validation and the attachment insert.
func (q *Queries) LockIssueForChannelMediaBind(ctx context.Context, arg LockIssueForChannelMediaBindParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockIssueForChannelMediaBind, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const lockIssueForDelete = `-- name: LockIssueForDelete :one
SELECT id FROM issue
WHERE id = $1 AND workspace_id = $2
FOR UPDATE
`
type LockIssueForDeleteParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Issue deletion must collect every attachment URL after it has won the same
// row-lock race used by channel media binding. FOR UPDATE conflicts with the
// binder's FOR KEY SHARE: either bind commits first and its URL is collected,
// or delete commits first and the binder leaves its durable intent for cleanup.
func (q *Queries) LockIssueForDelete(ctx context.Context, arg LockIssueForDeleteParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockIssueForDelete, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const lockIssueForDescriptionUpdate = `-- name: LockIssueForDescriptionUpdate :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, triage_state FROM issue
WHERE id = $1 AND workspace_id = $2
FOR UPDATE
`
type LockIssueForDescriptionUpdateParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Serialize field-baseline checks and combined attachment binding on the
// owner row. The handler merges channel media that landed after the editor's
// submitted base, updates the issue, and binds this request's attachments in
// the same transaction while holding this lock.
func (q *Queries) LockIssueForDescriptionUpdate(ctx context.Context, arg LockIssueForDescriptionUpdateParams) (Issue, error) {
row := q.db.QueryRow(ctx, lockIssueForDescriptionUpdate, 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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, 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 materializeIssueChannelMediaMarkdown = `-- name: MaterializeIssueChannelMediaMarkdown :one
UPDATE issue
SET description = CASE
WHEN $1::text IS NOT NULL
AND COALESCE(description, '') = $1::text
THEN $2::text
WHEN description IS NULL OR description = '' THEN $3
ELSE description || E'\n\n' || $3
END,
revision = revision + 1,
updated_at = now()
WHERE id = $4
AND workspace_id = $5
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, properties, revision, last_activity_at, triage_state
`
type MaterializeIssueChannelMediaMarkdownParams struct {
BaseDescription pgtype.Text `json:"base_description"`
Description string `json:"description"`
Markdown pgtype.Text `json:"markdown"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Detached channel media resolves after /issue creation. When the description
// still equals the exact creation-time base, replace its inline placeholders
// with the fully composed Markdown so rich-text ordering survives. If a user
// edited concurrently (or the adapter has no inline layout), append instead;
// preserving user-authored bytes takes precedence over layout fidelity.
// This is asynchronous system materialization, not a new user action, so it
// intentionally preserves last_activity_at while still advancing revision.
func (q *Queries) MaterializeIssueChannelMediaMarkdown(ctx context.Context, arg MaterializeIssueChannelMediaMarkdownParams) (Issue, error) {
row := q.db.QueryRow(ctx, materializeIssueChannelMediaMarkdown,
arg.BaseDescription,
arg.Description,
arg.Markdown,
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
const setIssueMetadataKey = `-- name: SetIssueMetadataKey :one
UPDATE issue SET
metadata = jsonb_set(metadata, ARRAY[$1::text], $2::jsonb),
revision = revision + 1,
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now()),
updated_at = now()
WHERE id = $3 AND workspace_id = $4
AND metadata -> $1::text IS DISTINCT FROM $2::jsonb
RETURNING id, workspace_id, metadata, revision
`
type SetIssueMetadataKeyParams struct {
Key string `json:"key"`
Value []byte `json:"value"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type SetIssueMetadataKeyRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Metadata []byte `json:"metadata"`
Revision int64 `json:"revision"`
}
// 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. A no-op, a missing issue, or
// a workspace mismatch returns no rows; callers that must distinguish those
// cases need a separate workspace-scoped read.
func (q *Queries) SetIssueMetadataKey(ctx context.Context, arg SetIssueMetadataKeyParams) (SetIssueMetadataKeyRow, error) {
row := q.db.QueryRow(ctx, setIssueMetadataKey,
arg.Key,
arg.Value,
arg.ID,
arg.WorkspaceID,
)
var i SetIssueMetadataKeyRow
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Metadata,
&i.Revision,
)
return i, err
}
const updateIssue = `-- name: UpdateIssue :one
WITH candidate AS (
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, i.triage_state,
COALESCE($3::text, i.title) AS next_title,
COALESCE($4::text, i.description) AS next_description,
COALESCE($5::text, i.status) AS next_status,
COALESCE($6::text, i.priority) AS next_priority,
$7::text AS next_assignee_type,
$8::uuid AS next_assignee_id,
CASE
-- An explicit position wins. Cross-column drag-and-drop sends
-- status and position together and means the slot it dropped on.
WHEN $9::double precision IS NOT NULL
THEN $9::double precision
-- position ranks an issue *within* its (workspace, status)
-- column, so it stops meaning anything the moment the column
-- changes: the value that put the issue on top of Todo lands it
-- below every hand-dragged issue in Done. Re-rank to the top of
-- the destination, the same policy new issues get from
-- NextTopPosition. Keep the two in sync.
--
-- Two status changes running at once can read the same MIN and
-- claim one slot. Every position-ordered query carries a unique
-- final key (created_at DESC, id DESC), so a tie leaves the
-- relative order of simultaneous moves arbitrary but never
-- unstable across pages. Creation avoids the tie by computing its
-- min under the workspace counter lock; a status change holds no
-- such lock and is not worth taking one for.
WHEN i.status IS DISTINCT FROM COALESCE($5::text, i.status)
THEN (
SELECT COALESCE(MIN(target.position), 0) - 1
FROM issue AS target
WHERE target.workspace_id = i.workspace_id
AND target.status = $5::text
)
ELSE i.position
END AS next_position,
$10::date AS next_start_date,
$11::date AS next_due_date,
$12::uuid AS next_parent_issue_id,
$13::uuid AS next_project_id,
$14::integer AS next_stage
FROM issue AS i
WHERE i.id = $1
AND ($2::bigint IS NULL OR i.revision = $2::bigint)
), changed AS (
SELECT
candidate.id, candidate.workspace_id, candidate.title, candidate.description, candidate.status, candidate.priority, candidate.assignee_type, candidate.assignee_id, candidate.creator_type, candidate.creator_id, candidate.parent_issue_id, candidate.acceptance_criteria, candidate.context_refs, candidate.position, candidate.due_date, candidate.created_at, candidate.updated_at, candidate.number, candidate.project_id, candidate.origin_type, candidate.origin_id, candidate.first_executed_at, candidate.start_date, candidate.metadata, candidate.stage, candidate.properties, candidate.revision, candidate.last_activity_at, candidate.triage_state, candidate.next_title, candidate.next_description, candidate.next_status, candidate.next_priority, candidate.next_assignee_type, candidate.next_assignee_id, candidate.next_position, candidate.next_start_date, candidate.next_due_date, candidate.next_parent_issue_id, candidate.next_project_id, candidate.next_stage,
ROW(
title, description, status, priority, assignee_type, assignee_id,
position, start_date, due_date, parent_issue_id, project_id, stage
) IS DISTINCT FROM ROW(
next_title, next_description, next_status, next_priority,
next_assignee_type, next_assignee_id, next_position, next_start_date,
next_due_date, next_parent_issue_id, next_project_id, next_stage
) AS did_change,
ROW(
title, description, status, priority, assignee_type, assignee_id,
start_date, due_date, parent_issue_id, project_id, stage
) IS DISTINCT FROM ROW(
next_title, next_description, next_status, next_priority,
next_assignee_type, next_assignee_id, next_start_date, next_due_date,
next_parent_issue_id, next_project_id, next_stage
) AS did_activity
FROM candidate
)
UPDATE issue AS i SET
title = changed.next_title,
description = changed.next_description,
status = changed.next_status,
priority = changed.next_priority,
assignee_type = changed.next_assignee_type,
assignee_id = changed.next_assignee_id,
position = changed.next_position,
start_date = changed.next_start_date,
due_date = changed.next_due_date,
parent_issue_id = changed.next_parent_issue_id,
project_id = changed.next_project_id,
stage = changed.next_stage,
revision = i.revision + changed.did_change::integer,
last_activity_at = CASE WHEN changed.did_activity
THEN GREATEST(COALESCE(i.last_activity_at, i.updated_at), now())
ELSE i.last_activity_at
END,
updated_at = CASE WHEN changed.did_change THEN now() ELSE i.updated_at END
FROM changed
WHERE i.id = changed.id
-- Re-check the precondition on the row version that UPDATE actually locks.
-- Under READ COMMITTED, concurrent statements may both populate candidate
-- from the same snapshot; EvalPlanQual re-evaluates this target-row predicate
-- after waiting for the first writer, leaving the stale writer with 0 rows.
AND ($2::bigint IS NULL OR i.revision = $2::bigint)
RETURNING 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, i.triage_state
`
type UpdateIssueParams struct {
ID pgtype.UUID `json:"id"`
ExpectedRevision pgtype.Int8 `json:"expected_revision"`
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.ExpectedRevision,
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}
const updateIssueStatus = `-- name: UpdateIssueStatus :one
UPDATE issue AS i SET
status = $2,
position = CASE WHEN i.status IS DISTINCT FROM $2 THEN (
SELECT COALESCE(MIN(target.position), 0) - 1
FROM issue AS target
WHERE target.workspace_id = i.workspace_id
AND target.status = $2
) ELSE i.position END,
revision = i.revision + CASE WHEN i.status IS DISTINCT FROM $2 THEN 1 ELSE 0 END,
last_activity_at = CASE WHEN i.status IS DISTINCT FROM $2
THEN GREATEST(COALESCE(i.last_activity_at, i.updated_at), now())
ELSE i.last_activity_at
END,
updated_at = now()
WHERE i.id = $1 AND i.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, properties, revision, last_activity_at, triage_state
`
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.
// Repositioning lives here rather than in the callers (GitHub sync, agent task
// completion) so a status write cannot land without one: an issue carrying its
// old column's rank into a new column is the bug this guards against. See the
// next_position CASE in UpdateIssue for the policy.
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,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
)
return i, err
}