mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* 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]>
1993 lines
67 KiB
Go
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
|
|
}
|