Files
Bohan Jiangandmultica-agent 1296bb45e0 MUL-7051 fix(subscribers): stop an armed autopilot from subscribing its trigger's creator (#8031)
* fix(subscribers): stop an armed autopilot from subscribing its trigger's creator

An autopilot fires on its schedule, its agent files issues, and the member who
armed the trigger is auto-subscribed to every one of them.

The delegated-subscriber rule (MUL-5483) subscribes the human an agent-created
issue exists on behalf of, and it tested that by asking whether the origin run
carries an originator. That was sound when it was written: direct_human was the
only attribution root that left an originator behind, so "this run carries a
human" and "a human asked for this work" were the same statement.

MUL-6951 separated them. An armed schedule/webhook trigger now runs with its
creator's authorization (trigger_owner), and attribution COPIES that human down
every agent hop, so the rule started reading an automation's authorization as a
request. attribution.DelegatedSubscriber already documents this exact outcome as
one it must not produce — the origin_type='autopilot' exclusion says the
autopilot's own subscriber template, not whoever armed the trigger, is the
audience for its issues. The regression arrives one hop below that exclusion,
where the issue's origin is an ordinary agent_create.

The rule now asks what it means: did this chain BEGIN with a member acting. The
originator alone cannot answer that below the first hop, because a hop relabels
its own originator_source to 'delegation' — so GetDelegatedSubscriptionFacts
walks delegated_from_task_id back to the run that actually resolved the human and
returns that root's label alongside the originator, in one workspace-scoped read
that replaces the previous single-task load. Accepting only direct_human makes it
a whitelist: the next root that resolves a human some other way stays silent here
until someone decides otherwise, instead of switching this rule on for a
population nobody chose.

No schema change and no change to attribution itself: originator, accountable and
every authorization decision are untouched. Only who gets subscribed moves.

Verification (real PostgreSQL 17): internal/attribution, cmd/server pass; the
four new tests fail against the unfixed rule, and the tenant-guard test fails
when the workspace predicate is removed from the new query. internal/handler and
internal/service each keep one pre-existing failure that reproduces identically
on a clean tree (source-context object-store cleanup), as do cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv.

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

* test(subscribers): close the review gaps in the lineage walk's coverage

Addresses Elon's review on #8031.

The 32-hop stop is accepted as a product limit (Bohan's call), so it is now
written down as behaviour instead of contradicted by it. DelegatedSubscriber
claimed "No depth cap", which was true of WHO the human is — the originator is
copied onto every run — but not of proving the chain began with them, which is
the part that walks. The query comment called the cap cycle protection only. Both
now say what actually happens: past 32 hops the root is unproven and nobody is
subscribed. TestDelegatedSubscribe_LineageWalkStopsAt32Hops pins both sides, so
the limit cannot drift silently; moving it to 3 reddens the 32-hop case.

The tenant test claimed to cover every hop and covered one. An issue pointing
straight at a foreign run only exercises the anchor's predicate, so deleting the
recursive branch's workspace_id left it green. It is now two cases, and the added
one — a local run naming a foreign parent — fails with that predicate removed
while the original case still passes, which is exactly the blind spot.

The chain fixture builds through internal/testutil instead of hand-writing an
INSERT with a matching cleanup, per the repository's test convention.

No production behaviour changes: the rule, the walk and every existing assertion
are untouched.

Verification (real PostgreSQL 17): cmd/server, internal/attribution,
internal/service pass; both new tests were run against a deliberately broken
query and fail there. gofmt, go vet, git diff --check clean.

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

---------

Co-authored-by: J <[email protected]>
2026-09-04 16:33:58 +08:00

529 lines
20 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: subscriber.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const addDelegatedSubscriber = `-- name: AddDelegatedSubscriber :execrows
WITH RECURSIVE ancestors(node_id, parent_id, depth) AS (
SELECT root.id, root.parent_issue_id, 0 FROM issue root WHERE root.id = $1
UNION ALL
SELECT i.id, i.parent_issue_id, a.depth + 1
FROM issue i JOIN ancestors a ON i.id = a.parent_id
),
active_member AS (
SELECT m.user_id FROM member m
WHERE m.user_id = $2 AND m.workspace_id = $4
FOR SHARE
)
INSERT INTO issue_subscriber (issue_id, user_type, user_id, reason)
SELECT $1, 'member', am.user_id, $3
FROM active_member am
WHERE NOT EXISTS (
SELECT 1
FROM issue_subscriber s
JOIN ancestors a ON a.node_id = s.issue_id
WHERE s.user_type = 'member' AND s.user_id = $2
AND s.unsubscribed_at IS NOT NULL
AND (a.depth = 0 OR s.opt_out_scope = 'subtree')
)
ON CONFLICT (issue_id, user_type, user_id) DO UPDATE
SET reason = EXCLUDED.reason
WHERE issue_subscriber.unsubscribed_at IS NULL
AND issue_subscriber.reason = 'delegated'
AND EXCLUDED.reason <> 'delegated'
`
type AddDelegatedSubscriberParams struct {
IssueID pgtype.UUID `json:"issue_id"`
UserID pgtype.UUID `json:"user_id"`
Reason string `json:"reason"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// The delegated rule's write (MUL-5483). Eligibility and insert are ONE
// statement so they share a snapshot; callers must hold LockSubscriberWrites
// for the same (workspace, user), which is what makes that snapshot stable.
//
// Eligibility is two conditions the previous version checked in separate
// round trips:
//
// 1. active_member — the originator must STILL be a workspace member.
// originator_user_id is stamped when the task is queued and never
// revisited, so a revoked member's still-running tasks would otherwise
// keep filing ghost subscribers. FOR SHARE also blocks a concurrent
// DELETE of that member row for the rest of this transaction, so the
// check cannot go stale between here and the insert.
//
// 2. NOT EXISTS (...) — the ancestor opt-out walk from HasAncestorOptOut,
// inlined for the same reason: a subtree tombstone committed between a
// separate check and this insert would silently undo the user's opt-out
// on every child the agent files next.
//
// ON CONFLICT keeps AddIssueSubscriber's two rules: never resurrect a
// tombstone, and upgrade an active 'delegated' row when the user becomes
// directly involved.
func (q *Queries) AddDelegatedSubscriber(ctx context.Context, arg AddDelegatedSubscriberParams) (int64, error) {
result, err := q.db.Exec(ctx, addDelegatedSubscriber,
arg.IssueID,
arg.UserID,
arg.Reason,
arg.WorkspaceID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const addIssueSubscriber = `-- name: AddIssueSubscriber :execrows
INSERT INTO issue_subscriber (issue_id, user_type, user_id, reason)
VALUES ($1, $2, $3, $4)
ON CONFLICT (issue_id, user_type, user_id) DO UPDATE
SET reason = EXCLUDED.reason
WHERE issue_subscriber.unsubscribed_at IS NULL
AND issue_subscriber.reason = 'delegated'
AND EXCLUDED.reason <> 'delegated'
`
type AddIssueSubscriberParams struct {
IssueID pgtype.UUID `json:"issue_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
Reason string `json:"reason"`
}
// Auto-subscribe path (creator / assignee / commenter / mentioned / autopilot /
// delegated).
//
// Two behaviors are load-bearing here:
//
// 1. A tombstoned row is NEVER resurrected. The WHERE on the DO UPDATE fails
// for an opted-out row, which degrades to DO NOTHING — so unsubscribe still
// sticks on a tree an agent keeps adding to (MUL-5483).
//
// 2. An ACTIVE 'delegated' row is upgraded when the user becomes directly
// involved (assigned / mentioned / commented). Without this the reason
// stays 'delegated' forever and someone who is now a real participant keeps
// getting the reduced delivery tier.
//
// Returns rows affected so the caller only broadcasts subscriber:added when an
// active subscription actually changed. Publishing unconditionally made the
// frontend insert a subscriber the DB had refused to write.
func (q *Queries) AddIssueSubscriber(ctx context.Context, arg AddIssueSubscriberParams) (int64, error) {
result, err := q.db.Exec(ctx, addIssueSubscriber,
arg.IssueID,
arg.UserType,
arg.UserID,
arg.Reason,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteSubscriptionsByMember = `-- name: DeleteSubscriptionsByMember :exec
DELETE FROM issue_subscriber s
USING issue i
WHERE s.issue_id = i.id
AND i.workspace_id = $1
AND s.user_type = 'member'
AND s.user_id = $2
`
type DeleteSubscriptionsByMemberParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
// Drop a departing member's subscriptions across the workspace, in the same tx
// as the member-row delete (MUL-5483 review round 7).
//
// Same application-layer cleanup rule the surrounding revoke path already
// applies to channel bindings and invocation grants: issue_subscriber carries
// no FK, so nothing removes these implicitly. Without it a revoked member keeps
// accruing inbox rows, and re-inviting them silently restores visibility of
// every issue they used to watch.
//
// A hard DELETE, not a tombstone: a tombstone means "this person chose to
// leave this issue" and would suppress re-subscription if they rejoin, which
// is not what a workspace revoke means.
func (q *Queries) DeleteSubscriptionsByMember(ctx context.Context, arg DeleteSubscriptionsByMemberParams) error {
_, err := q.db.Exec(ctx, deleteSubscriptionsByMember, arg.WorkspaceID, arg.UserID)
return err
}
const getDelegatedSubscriptionFacts = `-- name: GetDelegatedSubscriptionFacts :one
WITH RECURSIVE lineage AS (
SELECT atq.id, atq.originator_user_id, atq.originator_source, atq.delegated_from_task_id, 0 AS depth
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE atq.id = $1 AND a.workspace_id = $2
UNION ALL
SELECT parent.id, parent.originator_user_id, parent.originator_source,
parent.delegated_from_task_id, child.depth + 1
FROM lineage child
JOIN agent_task_queue parent ON parent.id = child.delegated_from_task_id
JOIN agent parent_agent ON parent_agent.id = parent.agent_id
WHERE child.originator_source IN ('delegation', 'comment_source')
AND parent_agent.workspace_id = $2
AND child.depth < 32
),
chain_root AS (
SELECT originator_source FROM lineage ORDER BY depth DESC LIMIT 1
)
SELECT origin.originator_user_id, chain_root.originator_source AS root_source
FROM lineage origin
CROSS JOIN chain_root
WHERE origin.depth = 0
`
type GetDelegatedSubscriptionFactsParams struct {
OriginTaskID pgtype.UUID `json:"origin_task_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type GetDelegatedSubscriptionFactsRow struct {
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
RootSource pgtype.Text `json:"root_source"`
}
// Loads the two facts the delegated-subscriber rule needs about the run that
// filed an issue: the human that run carried, and HOW the delegation chain the
// run belongs to acquired that human (MUL-5483, MUL-7051). One walk, so one
// round trip and one tenant guard rather than two.
//
// WHY the second fact is needed. Attribution COPIES the originator across every
// agent hop instead of chaining it (MUL-4302 §3.2), so originator_user_id alone
// cannot say why a chain has a human: a member's own comment and an autopilot
// trigger somebody armed months ago leave an identical value on every
// descendant run. Until MUL-6951 the difference did not have to be read —
// direct_human was the only root that left an originator at all, so "has an
// originator" WAS "a human asked for this". An armed trigger now carries its
// creator's authorization too, and that equivalence is what silently turned the
// rule into "subscribe whoever armed the automation" (MUL-7051).
//
// Only originator_source separates the two, and every hop relabels itself
// 'delegation' / 'comment_source' — so it is the chain ROOT's label that has to
// be read. delegated_from_task_id is the link back to the run a hop copied its
// human from: follow it while the label is still a hop label and the walk lands
// on the run that actually resolved the human.
//
// Tenancy: every hop re-joins agent and re-checks workspace_id — the same guard
// GetAgentTaskInWorkspace applies to a single task (MUL-4252) — so a foreign
// delegated_from_task_id can never pull a row from another tenant. Both hops
// carry it: the anchor's predicate is the one an issue pointing straight at a
// foreign run exercises, the recursive one is what stops a local run from
// claiming a parent outside the workspace.
//
// The 32-hop stop is a REAL product limit, not just cycle protection (a run can
// only reference an older run, so the lineage cannot cycle anyway): a chain
// deeper than that reports the hop label it stopped on and its human is not
// subscribed. Accepted deliberately — no observed chain comes close, and this
// read is on the issue-create path — see DelegatedSubscriber for the contract
// and what removing the limit would take.
//
// A lineage truncated by a deleted ancestor, or by that limit, reports the hop
// label it stopped on, never a root label, so the caller reads it as "not
// proven" instead of assuming a human asked.
func (q *Queries) GetDelegatedSubscriptionFacts(ctx context.Context, arg GetDelegatedSubscriptionFactsParams) (GetDelegatedSubscriptionFactsRow, error) {
row := q.db.QueryRow(ctx, getDelegatedSubscriptionFacts, arg.OriginTaskID, arg.WorkspaceID)
var i GetDelegatedSubscriptionFactsRow
err := row.Scan(&i.OriginatorUserID, &i.RootSource)
return i, err
}
const hasAncestorOptOut = `-- name: HasAncestorOptOut :one
WITH RECURSIVE ancestors(node_id, parent_id, depth) AS (
SELECT root.id, root.parent_issue_id, 0 FROM issue root WHERE root.id = $1
UNION ALL
SELECT i.id, i.parent_issue_id, a.depth + 1 FROM issue i JOIN ancestors a ON i.id = a.parent_id
)
SELECT EXISTS(
SELECT 1
FROM issue_subscriber s
JOIN ancestors a ON a.node_id = s.issue_id
WHERE s.user_type = $2 AND s.user_id = $3
AND s.unsubscribed_at IS NOT NULL
AND (a.depth = 0 OR s.opt_out_scope = 'subtree')
) AS opted_out
`
type HasAncestorOptOutParams struct {
ID pgtype.UUID `json:"id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
// True when the user has opted out in a way that should keep them off THIS
// issue.
//
// Two distinct cases, and the distinction is the whole point of opt_out_scope:
//
// - a tombstone on this issue itself, at any scope — they left this issue;
// - a 'subtree'-scoped tombstone on a STRICT ancestor — they left a tree this
// issue belongs to, so a child created an hour later is still covered.
//
// An 'issue'-scoped tombstone on an ancestor deliberately does NOT match: the
// user declined that one issue, not everything the agent files beneath it.
func (q *Queries) HasAncestorOptOut(ctx context.Context, arg HasAncestorOptOutParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAncestorOptOut, arg.ID, arg.UserType, arg.UserID)
var opted_out bool
err := row.Scan(&opted_out)
return opted_out, err
}
const isIssueSubscriber = `-- name: IsIssueSubscriber :one
SELECT EXISTS(
SELECT 1 FROM issue_subscriber
WHERE issue_id = $1 AND user_type = $2 AND user_id = $3
AND unsubscribed_at IS NULL
) AS subscribed
`
type IsIssueSubscriberParams struct {
IssueID pgtype.UUID `json:"issue_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) IsIssueSubscriber(ctx context.Context, arg IsIssueSubscriberParams) (bool, error) {
row := q.db.QueryRow(ctx, isIssueSubscriber, arg.IssueID, arg.UserType, arg.UserID)
var subscribed bool
err := row.Scan(&subscribed)
return subscribed, err
}
const listIssueSubscribers = `-- name: ListIssueSubscribers :many
SELECT issue_id, user_type, user_id, reason, created_at, unsubscribed_at, opt_out_scope FROM issue_subscriber
WHERE issue_id = $1 AND unsubscribed_at IS NULL
ORDER BY created_at
`
func (q *Queries) ListIssueSubscribers(ctx context.Context, issueID pgtype.UUID) ([]IssueSubscriber, error) {
rows, err := q.db.Query(ctx, listIssueSubscribers, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueSubscriber{}
for rows.Next() {
var i IssueSubscriber
if err := rows.Scan(
&i.IssueID,
&i.UserType,
&i.UserID,
&i.Reason,
&i.CreatedAt,
&i.UnsubscribedAt,
&i.OptOutScope,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockActiveMember = `-- name: LockActiveMember :one
SELECT id FROM member
WHERE user_id = $1 AND workspace_id = $2
FOR SHARE
`
type LockActiveMemberParams struct {
UserID pgtype.UUID `json:"user_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Re-assert workspace membership INSIDE a serialized transaction, holding the
// row so a concurrent revoke cannot complete underneath the caller
// (MUL-5483 review round 8).
//
// The subtree-unsubscribe handler validates membership from its own MVCC
// snapshot before it opens a transaction, which is only a statement about the
// past. A revoke that commits in between clears the user's subscriptions and
// deletes the member row, and the handler would then write a fresh tombstone
// behind it — leaving an opt-out that outlives the membership and is inherited
// if the person is ever re-invited.
func (q *Queries) LockActiveMember(ctx context.Context, arg LockActiveMemberParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockActiveMember, arg.UserID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const lockSubscriberWrites = `-- name: LockSubscriberWrites :exec
SELECT pg_advisory_xact_lock(
hashtext(($1::uuid)::text),
hashtext(($2::uuid)::text)
)
`
type LockSubscriberWritesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
// Transaction-scoped serialization boundary for (workspace, user) subscriber
// state (MUL-5483 review round 7).
//
// Three paths race over the same question — "should this user be an active
// watcher?" — and each one is a check in one statement followed by a write in
// another:
//
// - the delegated rule: is the user a member / not opted out, then insert;
// - subtree unsubscribe: tombstone the tree, which must cover children the
// agent files a moment later;
// - member revoke: delete the member row and their subscriptions.
//
// Row locks cannot close this. The interleaving that breaks the escape hatch
// inserts a subscriber for an issue that did not exist when the opt-out was
// written, so there is no row to lock — a phantom, which under READ COMMITTED
// needs an explicit lock object. Every path above takes THIS lock first, so
// their lock ordering is identical and they cannot deadlock against each other.
//
// Keyed on (workspace, user) rather than the issue: an opt-out is about a
// person and a tree, and the tree's membership is exactly what is changing.
//
// The key is derived from the UUID VALUE, not from however the caller happened
// to spell it. ::uuid parses the input and the outer ::text renders PostgreSQL's
// canonical lowercase form, so 'AB…' and 'ab…' — the same UUID everywhere else
// in this file, and to every membership query — also produce the same lock.
// Hashing the raw string instead let an uppercase request take a DIFFERENT lock
// from the canonical holder, which silently reopened the very race this lock
// exists to close. Casting at the DB boundary keeps that guarantee even if a
// future caller forgets to canonicalize.
func (q *Queries) LockSubscriberWrites(ctx context.Context, arg LockSubscriberWritesParams) error {
_, err := q.db.Exec(ctx, lockSubscriberWrites, arg.WorkspaceID, arg.UserID)
return err
}
const removeIssueSubscriber = `-- name: RemoveIssueSubscriber :exec
UPDATE issue_subscriber
SET unsubscribed_at = now(), opt_out_scope = 'issue'
WHERE issue_id = $1 AND user_type = $2 AND user_id = $3 AND unsubscribed_at IS NULL
`
type RemoveIssueSubscriberParams struct {
IssueID pgtype.UUID `json:"issue_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
// Leave THIS issue only. Tombstone rather than delete, so auto-subscribe rules
// can tell "never subscribed" (no row) from "chose to leave" (row with
// unsubscribed_at). scope='issue' keeps the opt-out from reaching descendants:
// future children of this issue are still allowed to subscribe the user.
func (q *Queries) RemoveIssueSubscriber(ctx context.Context, arg RemoveIssueSubscriberParams) error {
_, err := q.db.Exec(ctx, removeIssueSubscriber, arg.IssueID, arg.UserType, arg.UserID)
return err
}
const subscribeToIssueExplicitly = `-- name: SubscribeToIssueExplicitly :exec
INSERT INTO issue_subscriber (issue_id, user_type, user_id, reason)
VALUES ($1, $2, $3, $4)
ON CONFLICT (issue_id, user_type, user_id)
DO UPDATE SET unsubscribed_at = NULL, opt_out_scope = NULL, reason = EXCLUDED.reason
`
type SubscribeToIssueExplicitlyParams struct {
IssueID pgtype.UUID `json:"issue_id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
Reason string `json:"reason"`
}
// Explicit user action (the Subscribe button). Unlike the rule-driven path this
// CLEARS an existing opt-out tombstone and its scope: the user is overriding
// their own earlier unsubscribe, which is the one thing that should bring them
// back.
func (q *Queries) SubscribeToIssueExplicitly(ctx context.Context, arg SubscribeToIssueExplicitlyParams) error {
_, err := q.db.Exec(ctx, subscribeToIssueExplicitly,
arg.IssueID,
arg.UserType,
arg.UserID,
arg.Reason,
)
return err
}
const unsubscribeFromIssueSubtree = `-- name: UnsubscribeFromIssueSubtree :many
WITH RECURSIVE subtree(node_id) AS (
SELECT root.id FROM issue root WHERE root.id = $1
UNION ALL
SELECT i.id FROM issue i JOIN subtree s ON i.parent_issue_id = s.node_id
),
retire_descendants AS (
UPDATE issue_subscriber sub
SET unsubscribed_at = now(), opt_out_scope = 'subtree'
WHERE sub.issue_id IN (SELECT node_id FROM subtree WHERE node_id <> $1)
AND sub.user_type = $2 AND sub.user_id = $3
AND sub.unsubscribed_at IS NULL
RETURNING sub.issue_id
),
retire_root AS (
INSERT INTO issue_subscriber (issue_id, user_type, user_id, reason, unsubscribed_at, opt_out_scope)
VALUES ($1, $2, $3, 'manual', now(), 'subtree')
ON CONFLICT (issue_id, user_type, user_id)
DO UPDATE SET unsubscribed_at = now(), opt_out_scope = 'subtree'
RETURNING issue_id
)
SELECT issue_id FROM retire_descendants
UNION ALL
SELECT issue_id FROM retire_root
`
type UnsubscribeFromIssueSubtreeParams struct {
ID pgtype.UUID `json:"id"`
UserType string `json:"user_type"`
UserID pgtype.UUID `json:"user_id"`
}
// Leave an issue AND every descendant in one action. An agent-built tree is
// the unit a user actually wants to stop watching; leaving 30 sub-issues one
// at a time is not a real escape hatch.
//
// The root gets an UPSERTED tombstone rather than a conditional UPDATE, because
// "I don't want this tree" has to persist even when the user holds no
// subscription on the root itself — otherwise the opt-out records nothing and
// the next child the agent files re-subscribes them. That root tombstone is
// also what covers FUTURE descendants: HasAncestorOptOut walks up to it and
// honors it because its scope is 'subtree'.
//
// Returns every issue id it actually tombstoned so the caller can broadcast one
// subscriber:removed per issue; publishing only the root left other open tabs
// showing a stale subscription on the children.
func (q *Queries) UnsubscribeFromIssueSubtree(ctx context.Context, arg UnsubscribeFromIssueSubtreeParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, unsubscribeFromIssueSubtree, arg.ID, arg.UserType, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var issue_id pgtype.UUID
if err := rows.Scan(&issue_id); err != nil {
return nil, err
}
items = append(items, issue_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}