Files
multica/server/pkg/db/generated/comment.sql.go
e2f4a22030 feat: steer a running agent from the normal composer, per recipient (MUL-7631) (#8760)
* feat(server): steer running turns from ordinary comments (MUL-7631)

A comment can now name recipients whose running turn should receive it
(`steer_agent_ids` on POST /issues/:id/comments). Each named recipient
with a running, steer-capable turn gets the comment bound to that turn
instead of a follow-up run; any other recipient keeps its normal
queued / coalesced / deferred trigger, so losing a race never drops input.

- One comment can steer several turns: receipts are keyed by
  (comment_id, task_id) and returned as `supplements` on comments and
  timeline entries. The single supplement_* fields mirror the first
  receipt for older clients.
- Completion reconciliation skips a steered comment only for the agent
  it steered, so the same comment still reaches recipients that were
  addressed without steering.
- Delivery claims match the (comment, run) pair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): choose per recipient how a message reaches a running agent (MUL-7631)

Steering moves from a separate "Add message" box on the run row into the
normal reply and comment composers. Recipients keep the existing trigger
rules; each recipient's current run on the issue decides what the message
can do:

- running (and steer-capable): add to the current run (default in that
  run's own thread), start after this run, stop and start over, or skip
- queued: include when it starts, or skip
- idle: start on send, or skip

Receipts follow the message ("Waiting for Lambda to read it" → "Read by
Lambda · after step N"), a failed delivery offers retry or sending it as
a new run, and the run's step list marks where it read the message.
A draft whose recipient finished meanwhile stays in place and says it
will start a new run; a message with files is handled after the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): steer only the chosen turn, and make a retried steer idempotent (MUL-7631)

- Steering names the exact running turns the author chose
  (`steer_task_ids`). A chosen turn that ended before the send arrives is
  never swapped for a later turn of the same agent; that recipient keeps
  its normal trigger.
- A steering send carries `client_request_id`. A retry after a lost
  response returns the original comment (200) instead of creating a second
  comment and a second delivery; a concurrent twin binding the same request
  is treated as already steered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): guard steering sends against stale previews and broad resends (MUL-7631)

- "Stop and start over" never stops a run while the trigger preview is
  still catching up with an edited @mention: the stop is dropped unless the
  preview answers the submitted text.
- Steering sends the chosen turns' task ids and one request id per draft
  text, reused on retries of the same text.
- "Send as a new run" on a failed receipt reaches only that receipt's
  agent: every other current recipient is suppressed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): keep the reply box placeholder neutral (MUL-7631)

The empty reply box cannot know who a reply goes to: the author may
@ someone else or only talk to a teammate. The recipient chip already
says what Send will do once there is text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(issues): give steering composer tests room for the preview debounce (MUL-7631)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): choose the default reply to a running agent (MUL-7631)

Preferences > Comments gains "When replying to a running agent":
"Add to current run" (default) or "Start after this run". It only
changes what a reply does without a per-message choice; the recipient
chip still offers both, and replies that never steered by default
(another thread, an idle or queued agent) are unaffected. Saved on this
device next to the sticky comment bar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): save one comment per send and honor "don't start" on replay (MUL-7631)

Idempotency now lives on the comment, not the delivery receipt. A send's
client_request_id is stored on the comment behind a partial unique index
(migrations 550/551), so a retry, or a concurrent twin, returns the saved
comment (200) whether the send steered a running turn or fell back to a
normal trigger. Before, a twin could save a second comment, and a send whose
turn had ended kept no receipt to find.

The agents an author chose not to start are stored on the comment too, and
completion replay skips them. "Send as a new run" for one missed recipient,
and "Won't start this time", no longer wake the other agent once its current
run completes. An edit re-records the choice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): repeat the original steering request on retry (MUL-7631)

Until a send is settled, retrying the same text sends its first steering
request unchanged, even after the chosen turn ended. The server then returns
the comment the first attempt saved instead of posting it again as new input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(migrations): fail fast on locks in 549 and 550 (MUL-7631)

Both take an ACCESS EXCLUSIVE lock on a table the product touches
continuously: comment for every read and write, task_supplement for every
daemon claim. Bound lock acquisition and execution, as 483 does for issue,
so a long transaction makes the migration fail and retry on the next run
instead of queueing all traffic behind it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): steer at most one turn per comment during rollout (MUL-7631)

Steering is live, and servers from before per-run receipts claim every
receipt of a comment at once. While they still serve daemons during a
rollout, a comment bound to two turns could have the second copy marked in
flight and never delivered. Until every server claims per (comment, run),
a comment steers the first chosen turn only; later recipients keep their
normal trigger. A follow-up lifts the limit after this ships.

Also covers a steer_task_ids entry that names another issue's turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): finish a saved send on retry; keep an edit's choices with its text (MUL-7631)

A send's comment was saved before its agents were started or steered, and
deduplication treated the saved comment as the whole send. A dropped
connection cancelled the request between the two, and the retry only
returned the comment, so no agent ever received it.

- Attempts of one logical send now run one at a time under a transaction
  advisory lock, held on its own connection: a twin waits for the attempt in
  progress (up to 30s, then 409) instead of racing it, and a process that
  dies releases it.
- After the comment is saved, dispatch runs on a non-cancelable context, and
  comment.client_request_dispatched_at (migration 552) records that it
  finished. A retry that finds a saved but undispatched comment finishes the
  dispatch with the comment's recorded choices.

An edit's "don't start" choices were written by a separate statement after
the text, so interleaved edits could leave an older edit's choice. They are
now part of UpdateComment, under the same revision check as the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): offer one steered recipient per message during rollout (MUL-7631)

The server steers at most one turn per comment until #8800, but the
composer showed "Add to current run" for every running recipient and the
server quietly queued the rest. Now only one recipient steers: the one the
author picked, otherwise the first that would by default. The others show
"Start after this run", and picking "Add to current run" on one of them
moves the steer there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): claim a send before dispatching it; drop the request lock (MUL-7631)

The previous recovery marked a send dispatched only after reaching its
agents, so a failure after dispatch made a retry reach them all again. It
also held a pooled connection per send for an advisory lock while the send
needed more from the same pool, which could wait on itself until timeout.

A send's comment now holds a dispatch claim instead
(comment.client_request_dispatched_at, one conditional update): only the
attempt whose claim takes effect publishes the comment and reaches its
agents. A failed claim writes nothing else and returns 500 for the client
to retry; a retry that finds an unclaimed comment claims and dispatches
it; a claimed one is only returned. Concurrent attempts no longer wait on
each other, and post-save dispatch keeps a 30s bound of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(server): let one comment steer several running turns (MUL-7631)

Lifts the rollout limit from #8760. Every server now claims receipts per
(comment, run), so a comment can be bound to each running turn its author
chose, and each recipient receives it in its own run.

Merge only after #8760 is deployed everywhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): steer every running recipient a message addresses (MUL-7631)

The client side of lifting the rollout limit: each running recipient can
take the message in its current turn again, instead of only one per
message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(issues): drop send-retry idempotency and two display extras (MUL-7631)

A steering send now behaves like any other comment when its response is
lost: a retry posts it again. The machinery that tried to make that retry
return the saved comment grew with every edge it met, and ordinary comments
never had it:

- Remove comment.client_request_id, its unique index, and the dispatch
  claim (migrations 551/552, and 550 reduced to suppressed_agent_ids).
- Remove the replay/resume path in CreateComment, the post-save
  non-cancelable dispatch context, and the client's sticky request id.
  Steering sends only `steer_task_ids`; each binding uses the comment id
  as its receipt request id.

Two display extras go too, since the UI already shows the same thing:

- The "run ended while you were writing" notice: the recipient chip
  already switches from "Add to current run" to its new action.
- "after step N" on a read receipt: the run trace already places the
  message at the step where it was read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): space agent names in Chinese copy; neutral recipients header (MUL-7631)

- The steer badge joined names with Intl.ListFormat, which gives
  "Lambda和Orion" in Chinese. Latin names are now spaced from the Chinese
  joiner ("Lambda 和 Orion"), per the zh copy conventions; the enumeration
  comma stays unspaced.
- The multi-recipient popover was still titled "Will start when sent" and
  its tooltip said "choose who runs", though each row now picks how that
  agent handles the message. Title it "Recipients" and reword the tooltip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): translate the recipient source labels in Chinese (MUL-7631)

"assignee" and "@mention" were left in English in zh-Hans; the recipient
menu header shows them. Use 负责人 (per the zh conventions) and @提及.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-25 04:42:26 +08:00

2344 lines
83 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: comment.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const bumpCommentRevision = `-- name: BumpCommentRevision :one
UPDATE comment
SET revision = revision + 1,
updated_at = now()
WHERE id = $1
AND workspace_id = $2
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
`
type BumpCommentRevisionParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) BumpCommentRevision(ctx context.Context, arg BumpCommentRevisionParams) (Comment, error) {
row := q.db.QueryRow(ctx, bumpCommentRevision, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const clearOtherThreadResolutions = `-- name: ClearOtherThreadResolutions :many
WITH RECURSIVE root_of AS (
-- Walk up from the target to its thread root.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.issue_id = $2 AND c.workspace_id = $3
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Expand back down from the root over the whole subtree. Cycle-safe under
-- the PK constraint (a comment cannot be its own ancestor).
SELECT c.id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = $2 AND c.workspace_id = $3
)
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
revision = revision + 1,
updated_at = now()
WHERE comment.id IN (SELECT id FROM descendants)
AND comment.id <> $1
AND comment.resolved_at IS NOT NULL
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
`
type ClearOtherThreadResolutionsParams struct {
TargetID pgtype.UUID `json:"target_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Single-resolution invariant: a thread has at most one resolved comment.
// Resolving @target_id makes it the sole resolution, so this clears resolved_at
// on every OTHER currently-resolved comment in the same thread (the root of
// @target_id plus every descendant). The handler runs this in the SAME tx as
// ResolveComment so the replace is atomic — a crash can never leave two
// resolutions or zero. Scope is the thread only (id IN descendants AND
// id <> @target_id), never the whole issue. Returns each cleared row so the
// handler can emit a comment:unresolved event per row; granular realtime
// consumers replace a single comment in place and would otherwise keep
// displaying the stale resolution.
func (q *Queries) ClearOtherThreadResolutions(ctx context.Context, arg ClearOtherThreadResolutionsParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, clearOtherThreadResolutions, arg.TargetID, arg.IssueID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const commentHasReplies = `-- name: CommentHasReplies :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE parent_id = $1 AND workspace_id = $2
) AS has_replies
`
type CommentHasRepliesParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) CommentHasReplies(ctx context.Context, arg CommentHasRepliesParams) (bool, error) {
row := q.db.QueryRow(ctx, commentHasReplies, arg.ID, arg.WorkspaceID)
var has_replies bool
err := row.Scan(&has_replies)
return has_replies, err
}
const countComments = `-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2
`
type CountCommentsParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) {
row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID)
var count int64
err := row.Scan(&count)
return count, err
}
const countNewCommentsSince = `-- name: CountNewCommentsSince :one
SELECT count(*) FROM comment
WHERE issue_id = $1
AND workspace_id = $2
AND created_at > $3
AND id <> $4
AND deleted_at IS NULL
AND NOT (author_type = 'agent' AND author_id = $5)
`
type CountNewCommentsSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
AnchorID pgtype.UUID `json:"anchor_id"`
AuthorID pgtype.UUID `json:"author_id"`
}
// Counts comments on an issue created strictly after @since, ACROSS THE WHOLE
// ISSUE (every thread, not just the triggering one). Excludes the triggering
// comment itself (@anchor_id — its body is already injected into the prompt)
// and any authored by the given agent (@author_id), so a chatty agent does not
// inflate its own new-comment count. The agent is steered to read the
// triggering thread first (see BuildNewCommentsHint), but the count is
// issue-wide so it knows the full catch-up volume. Feeds the daemon claim
// response without shipping comment bodies.
func (q *Queries) CountNewCommentsSince(ctx context.Context, arg CountNewCommentsSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countNewCommentsSince,
arg.IssueID,
arg.WorkspaceID,
arg.Since,
arg.AnchorID,
arg.AuthorID,
)
var count int64
err := row.Scan(&count)
return count, err
}
const createComment = `-- name: CreateComment :one
WITH touched_issue AS (
UPDATE issue SET
updated_at = now(),
revision = revision + 1,
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now())
WHERE issue.id = $1 AND issue.workspace_id = $2
RETURNING issue.id, issue.workspace_id, issue.revision
), inserted_comment AS (
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, source_task_id, quick_action_id, via_plugin_id, suppressed_agent_ids, id)
SELECT ti.id, ti.workspace_id, $3, $4, $5, $6, $7, $8, $9, $10, $11::uuid[], COALESCE($12::uuid, gen_random_uuid())
FROM touched_issue ti
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
)
SELECT inserted_comment.id, inserted_comment.issue_id, inserted_comment.author_type, inserted_comment.author_id, inserted_comment.content, inserted_comment.type, inserted_comment.created_at, inserted_comment.updated_at, inserted_comment.parent_id, inserted_comment.workspace_id, inserted_comment.resolved_at, inserted_comment.resolved_by_type, inserted_comment.resolved_by_id, inserted_comment.source_task_id, inserted_comment.quick_action_id, inserted_comment.via_plugin_id, inserted_comment.revision, inserted_comment.recovery_settled_at, inserted_comment.deleted_at, inserted_comment.suppressed_agent_ids, touched_issue.revision AS issue_revision
FROM inserted_comment
JOIN touched_issue ON touched_issue.id = inserted_comment.issue_id
`
type CreateCommentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID pgtype.UUID `json:"parent_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
ViaPluginID pgtype.UUID `json:"via_plugin_id"`
SuppressedAgentIds []pgtype.UUID `json:"suppressed_agent_ids"`
ID pgtype.UUID `json:"id"`
}
type CreateCommentRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
ViaPluginID pgtype.UUID `json:"via_plugin_id"`
Revision int64 `json:"revision"`
RecoverySettledAt pgtype.Timestamptz `json:"recovery_settled_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
SuppressedAgentIds []pgtype.UUID `json:"suppressed_agent_ids"`
IssueRevision int64 `json:"issue_revision"`
}
// A new comment counts as activity on its issue, so the same statement bumps
// the parent issue's updated_at and last_activity_at. The touch is a leading data-modifying CTE and
// the INSERT selects the issue/workspace back out of it, which makes the two
// inseparable and gives two query-level guarantees:
// - atomicity — the insert and the timestamp bump commit or roll back
// together, so an issue is never left with a stale updated_at after a
// comment persists; and
// - tenant integrity — the comment can only be created against an issue that
// actually exists in the given workspace. A mismatched (issue, workspace)
// pair matches 0 rows in the CTE, the dependent INSERT then selects nothing,
// and the :one query returns pgx.ErrNoRows. A wrong workspace can therefore
// never leave a mis-attributed comment or a silently un-touched issue.
//
// Centralizing this here means every comment entrypoint inherits both
// guarantees regardless of what a caller passes. The "Updated date" sort and
// the daemon GC TTL both read updated_at, so this consistency is load-bearing.
func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (CreateCommentRow, error) {
row := q.db.QueryRow(ctx, createComment,
arg.IssueID,
arg.WorkspaceID,
arg.AuthorType,
arg.AuthorID,
arg.Content,
arg.Type,
arg.ParentID,
arg.SourceTaskID,
arg.QuickActionID,
arg.ViaPluginID,
arg.SuppressedAgentIds,
arg.ID,
)
var i CreateCommentRow
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
&i.IssueRevision,
)
return i, err
}
const deleteLeafComment = `-- name: DeleteLeafComment :one
DELETE FROM comment
WHERE comment.id = $1 AND comment.workspace_id = $2
AND NOT EXISTS (SELECT 1 FROM comment child WHERE child.parent_id = comment.id)
RETURNING comment.id, comment.parent_id
`
type DeleteLeafCommentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type DeleteLeafCommentRow struct {
ID pgtype.UUID `json:"id"`
ParentID pgtype.UUID `json:"parent_id"`
}
// Removes a comment that has no replies. The NOT EXISTS guard keeps this
// statement from ever reaching the legacy parent_id cascade: a comment with a
// reply is tombstoned instead, never deleted with its replies.
func (q *Queries) DeleteLeafComment(ctx context.Context, arg DeleteLeafCommentParams) (DeleteLeafCommentRow, error) {
row := q.db.QueryRow(ctx, deleteLeafComment, arg.ID, arg.WorkspaceID)
var i DeleteLeafCommentRow
err := row.Scan(&i.ID, &i.ParentID)
return i, err
}
const deleteReplylessCommentTombstone = `-- name: DeleteReplylessCommentTombstone :one
DELETE FROM comment
WHERE comment.id = $1 AND comment.workspace_id = $2
AND comment.deleted_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM comment child WHERE child.parent_id = comment.id)
RETURNING comment.id, comment.parent_id
`
type DeleteReplylessCommentTombstoneParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type DeleteReplylessCommentTombstoneRow struct {
ID pgtype.UUID `json:"id"`
ParentID pgtype.UUID `json:"parent_id"`
}
// Removes a tombstone whose last reply is gone. The delete transaction calls
// this for each ancestor in turn, so a chain of placeholders never outlives
// the replies it kept attached.
func (q *Queries) DeleteReplylessCommentTombstone(ctx context.Context, arg DeleteReplylessCommentTombstoneParams) (DeleteReplylessCommentTombstoneRow, error) {
row := q.db.QueryRow(ctx, deleteReplylessCommentTombstone, arg.ID, arg.WorkspaceID)
var i DeleteReplylessCommentTombstoneRow
err := row.Scan(&i.ID, &i.ParentID)
return i, err
}
const getComment = `-- name: GetComment :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE id = $1
`
func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
row := q.db.QueryRow(ctx, getComment, id)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const getCommentInWorkspace = `-- name: GetCommentInWorkspace :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE id = $1 AND workspace_id = $2
`
type GetCommentInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetCommentInWorkspace(ctx context.Context, arg GetCommentInWorkspaceParams) (Comment, error) {
row := q.db.QueryRow(ctx, getCommentInWorkspace, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const getDelegatedFailureRecoveryComment = `-- name: GetDelegatedFailureRecoveryComment :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE issue_id = $1
AND workspace_id = $2
AND author_type = 'system'
AND type = 'progress_update'
AND source_task_id = $3
ORDER BY created_at ASC, id ASC
LIMIT 1
`
type GetDelegatedFailureRecoveryCommentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
}
// The failed task row is locked by the caller before this lookup/insert pair,
// making (source issue, failed task) a durable idempotency key without a new
// hot-table index or schema migration. Platform recovery signals are the only
// system-authored progress updates that carry source_task_id.
func (q *Queries) GetDelegatedFailureRecoveryComment(ctx context.Context, arg GetDelegatedFailureRecoveryCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, getDelegatedFailureRecoveryComment, arg.IssueID, arg.WorkspaceID, arg.SourceTaskID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const getDelegatedFailureRecoveryExhaustionComment = `-- name: GetDelegatedFailureRecoveryExhaustionComment :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE issue_id = $1
AND workspace_id = $2
AND author_type = 'system'
AND type = 'system'
AND source_task_id = $3
ORDER BY created_at ASC, id ASC
LIMIT 1
`
type GetDelegatedFailureRecoveryExhaustionCommentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
}
// The failed task and newest recovery-attempt row are locked by the caller
// before this lookup/insert pair. Keeping exhaustion as a separate system
// comment preserves the original recovery signal while making the automatic
// stop visible in the issue timeline.
func (q *Queries) GetDelegatedFailureRecoveryExhaustionComment(ctx context.Context, arg GetDelegatedFailureRecoveryExhaustionCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, getDelegatedFailureRecoveryExhaustionComment, arg.IssueID, arg.WorkspaceID, arg.SourceTaskID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const getLatestMemberCommentForIssueSince = `-- name: GetLatestMemberCommentForIssueSince :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE issue_id = $1
AND author_type = 'member'
AND created_at > $2
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 1
`
type GetLatestMemberCommentForIssueSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Since pgtype.Timestamptz `json:"since"`
}
// MUL-4195 completion reconciliation: the newest MEMBER-authored comment on an
// issue created strictly after @since (a run's started_at). Used when a task
// completes to detect deliberate user input that landed while the agent was
// busy — or that was merged into the running task after its context was
// already built — so a single follow-up run can be scheduled for it. Restricted
// to author_type = 'member' on purpose: only human input earns the guaranteed
// follow-up, which preserves the existing anti-loop guarantees (agent replies,
// acknowledgements, and self-triggers never qualify). Returns pgx.ErrNoRows
// when nothing newer exists, i.e. the run already covered the latest input.
func (q *Queries) GetLatestMemberCommentForIssueSince(ctx context.Context, arg GetLatestMemberCommentForIssueSinceParams) (Comment, error) {
row := q.db.QueryRow(ctx, getLatestMemberCommentForIssueSince, arg.IssueID, arg.Since)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const getThreadRoot = `-- name: GetThreadRoot :one
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.workspace_id = $2
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type, c.created_at, c.updated_at, c.parent_id, c.workspace_id, c.resolved_at, c.resolved_by_type, c.resolved_by_id, c.source_task_id, c.quick_action_id, c.via_plugin_id, c.revision, c.recovery_settled_at, c.deleted_at, c.suppressed_agent_ids FROM comment c
WHERE c.id = (SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1)
`
type GetThreadRootParams struct {
CommentID pgtype.UUID `json:"comment_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Returns the thread-root comment for @comment_id by walking parent_id up to
// the row whose parent_id IS NULL. For a root comment it returns that comment
// itself. Used when callers need thread-level behavior while parent_id remains
// the exact direct parent of a reply. Cycle-safe under the PK constraint (a
// comment cannot be its own ancestor).
func (q *Queries) GetThreadRoot(ctx context.Context, arg GetThreadRootParams) (Comment, error) {
row := q.db.QueryRow(ctx, getThreadRoot, arg.CommentID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const hasAgentCommentedSince = `-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = $1
AND author_type = 'agent'
AND author_id = $2
AND created_at >= $3
AND deleted_at IS NULL
) AS commented
`
type HasAgentCommentedSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AuthorID pgtype.UUID `json:"author_id"`
Since pgtype.Timestamptz `json:"since"`
}
func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommentedSinceParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentCommentedSince, arg.IssueID, arg.AuthorID, arg.Since)
var commented bool
err := row.Scan(&commented)
return commented, err
}
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
`
type HasAgentRepliedInThreadParams struct {
ParentID pgtype.UUID `json:"parent_id"`
AgentID pgtype.UUID `json:"agent_id"`
}
// Returns true if the given agent has posted a reply in the thread rooted at
// the specified parent comment. Used to detect agent participation in a
// member-started thread so that follow-up member replies still trigger the agent.
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
var has_replied bool
err := row.Scan(&has_replied)
return has_replied, err
}
const listChildCommentsForParents = `-- name: ListChildCommentsForParents :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE parent_id = ANY($1::uuid[])
AND issue_id = $2
AND workspace_id = $3
AND (created_at, id) <= ($4::timestamptz, $5::uuid)
ORDER BY parent_id ASC, created_at ASC, id ASC
LIMIT $6
`
type ListChildCommentsForParentsParams struct {
ParentIds []pgtype.UUID `json:"parent_ids"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ThroughAt pgtype.Timestamptz `json:"through_at"`
ThroughID pgtype.UUID `json:"through_id"`
RowLimit int32 `json:"row_limit"`
}
// Fetch one descendant level for a bounded set of parent comments. Each parent
// maps back to its thread root in the Go breadth-first walk, avoiding one query
// per root.
//
// Both tenant predicates apply at every level. row_limit is always a probe
// limit owned by completeCommentThreads; it prevents a wide level from defeating
// the response budget. through_at/through_id pin the walk to the newest row in
// the original window, so replies created concurrently are left to realtime
// delivery instead of making the multi-query snapshot internally inconsistent.
func (q *Queries) ListChildCommentsForParents(ctx context.Context, arg ListChildCommentsForParentsParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listChildCommentsForParents,
arg.ParentIds,
arg.IssueID,
arg.WorkspaceID,
arg.ThroughAt,
arg.ThroughID,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentAncestorPath = `-- name: ListCommentAncestorPath :many
WITH RECURSIVE ancestor_path AS (
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type, c.created_at, c.updated_at, c.parent_id, c.workspace_id, c.resolved_at, c.resolved_by_type, c.resolved_by_id, c.source_task_id, c.quick_action_id, c.via_plugin_id, c.revision, c.recovery_settled_at, c.deleted_at, c.suppressed_agent_ids, ARRAY[c.id]::uuid[] AS visited_ids, 1::integer AS depth, false AS cycle
FROM comment c
WHERE c.id = $1
AND c.workspace_id = $2
AND c.issue_id = $3
UNION ALL
SELECT parent.id, parent.issue_id, parent.author_type, parent.author_id, parent.content, parent.type, parent.created_at, parent.updated_at, parent.parent_id, parent.workspace_id, parent.resolved_at, parent.resolved_by_type, parent.resolved_by_id, parent.source_task_id, parent.quick_action_id, parent.via_plugin_id, parent.revision, parent.recovery_settled_at, parent.deleted_at, parent.suppressed_agent_ids,
path.visited_ids || parent.id,
path.depth + 1,
parent.id = ANY(path.visited_ids)
FROM ancestor_path path
JOIN comment parent ON parent.id = path.parent_id
WHERE parent.workspace_id = $2
AND parent.issue_id = $3
AND path.depth <= 256
AND NOT path.cycle
)
SELECT id, issue_id, author_type, author_id, content, type, created_at,
updated_at, workspace_id, parent_id, resolved_at, resolved_by_type,
resolved_by_id, source_task_id, revision, quick_action_id, via_plugin_id,
deleted_at, depth, cycle
FROM ancestor_path
ORDER BY depth DESC
`
type ListCommentAncestorPathParams struct {
CommentID pgtype.UUID `json:"comment_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
}
type ListCommentAncestorPathRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
Revision int64 `json:"revision"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
ViaPluginID pgtype.UUID `json:"via_plugin_id"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
Depth int32 `json:"depth"`
Cycle bool `json:"cycle"`
}
func (q *Queries) ListCommentAncestorPath(ctx context.Context, arg ListCommentAncestorPathParams) ([]ListCommentAncestorPathRow, error) {
rows, err := q.db.Query(ctx, listCommentAncestorPath, arg.CommentID, arg.WorkspaceID, arg.IssueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCommentAncestorPathRow{}
for rows.Next() {
var i ListCommentAncestorPathRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.WorkspaceID,
&i.ParentID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.Revision,
&i.QuickActionID,
&i.ViaPluginID,
&i.DeletedAt,
&i.Depth,
&i.Cycle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentThreadHistory = `-- name: ListCommentThreadHistory :many
WITH RECURSIVE thread_history AS (
SELECT root.id, root.issue_id, root.author_type, root.author_id, root.content, root.type, root.created_at, root.updated_at, root.parent_id, root.workspace_id, root.resolved_at, root.resolved_by_type, root.resolved_by_id, root.source_task_id, root.quick_action_id, root.via_plugin_id, root.revision, root.recovery_settled_at, root.deleted_at, root.suppressed_agent_ids
FROM comment root
WHERE root.id = $2
AND root.workspace_id = $3
AND root.issue_id = $4
AND root.parent_id IS NULL
AND (root.created_at, root.id) <= (
$5::timestamptz,
$6::uuid
)
UNION ALL
SELECT child.id, child.issue_id, child.author_type, child.author_id, child.content, child.type, child.created_at, child.updated_at, child.parent_id, child.workspace_id, child.resolved_at, child.resolved_by_type, child.resolved_by_id, child.source_task_id, child.quick_action_id, child.via_plugin_id, child.revision, child.recovery_settled_at, child.deleted_at, child.suppressed_agent_ids
FROM comment child
JOIN thread_history parent ON child.parent_id = parent.id
WHERE child.workspace_id = $3
AND child.issue_id = $4
AND (child.created_at, child.id) <= (
$5::timestamptz,
$6::uuid
)
)
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
FROM thread_history
ORDER BY created_at, id
LIMIT $1
`
type ListCommentThreadHistoryParams struct {
RowLimit int32 `json:"row_limit"`
RootID pgtype.UUID `json:"root_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
AnchorCreatedAt pgtype.Timestamptz `json:"anchor_created_at"`
AnchorID pgtype.UUID `json:"anchor_id"`
}
type ListCommentThreadHistoryRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
ViaPluginID pgtype.UUID `json:"via_plugin_id"`
Revision int64 `json:"revision"`
RecoverySettledAt pgtype.Timestamptz `json:"recovery_settled_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
SuppressedAgentIds []pgtype.UUID `json:"suppressed_agent_ids"`
}
// Capture the anchor comment's complete chronological thread through that
// comment. Later replies are outside the immutable context boundary.
// UUID is the stable tiebreaker used by the issue timeline when timestamps tie.
func (q *Queries) ListCommentThreadHistory(ctx context.Context, arg ListCommentThreadHistoryParams) ([]ListCommentThreadHistoryRow, error) {
rows, err := q.db.Query(ctx, listCommentThreadHistory,
arg.RowLimit,
arg.RootID,
arg.WorkspaceID,
arg.IssueID,
arg.AnchorCreatedAt,
arg.AnchorID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCommentThreadHistoryRow{}
for rows.Next() {
var i ListCommentThreadHistoryRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsByIDsForIssue = `-- name: ListCommentsByIDsForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE id = ANY($1::uuid[])
AND issue_id = $2
AND workspace_id = $3
ORDER BY created_at ASC, id ASC
`
type ListCommentsByIDsForIssueParams struct {
Ids []pgtype.UUID `json:"ids"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// The subset of @ids that exists within this issue and workspace.
//
// Used to walk parent chains one level at a time (see completeCommentThreads).
// Deliberately NOT a recursive CTE: an earlier revision walked parent_id upward
// in SQL, which had no depth bound — a deep chain could pull tens of thousands of
// ancestors back and defeat the whole point of the row cap — and its recursive
// branch matched on parent_id alone, so a stray cross-workspace parent reference
// would have dragged another tenant's comments into the response. Both tenant
// columns are required here on every level, and the caller owns the budget.
func (q *Queries) ListCommentsByIDsForIssue(ctx context.Context, arg ListCommentsByIDsForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsByIDsForIssue, arg.Ids, arg.IssueID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsForIssue = `-- name: ListCommentsForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM (
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at DESC, id DESC
LIMIT $3
) AS recent
ORDER BY created_at ASC, id ASC
`
type ListCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// The NEWEST $3 comments for an issue, returned in chronological order.
//
// Same shape and same reason as ListActivitiesForIssue: the inner query takes
// the window with the keyset ordering so the cap discards the OLDEST rows, and
// the outer query restores the ascending contract callers rely on. The ordering
// of the inner window is satisfied by idx_comment_issue_keyset (migration 068);
// the outer query sorts that bounded window back into chronological order.
//
// A newest-N window is a suffix of the timeline, and unlike a prefix it is NOT
// closed under "parent of": a reply is always newer than its parent, so an old
// thread root can fall outside the window while a fresh reply to it stays
// inside. Callers that render threads must close the parent chains afterwards —
// see completeCommentThreads (MUL-5492).
//
// The cap is still purely defensive here — issue p99 is ~30 comments and the max
// ever observed in prod is ~1.1k — but "defensive" is not a reason to drop the
// newest rows when it does fire (MUL-5492).
func (q *Queries) ListCommentsForIssue(ctx context.Context, arg ListCommentsForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsForIssue, arg.IssueID, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSinceForIssue = `-- name: ListCommentsSinceForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC, id ASC
LIMIT $4
`
type ListCommentsSinceForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Limit int32 `json:"limit"`
}
// Comments created strictly after $3 in chronological order, capped at $4.
// Powers the CLI's `--since` agent-polling flow.
func (q *Queries) ListCommentsSinceForIssue(ctx context.Context, arg ListCommentsSinceForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSinceForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.CreatedAt,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentThreadCommentsForIssue = `-- name: ListRecentThreadCommentsForIssue :many
WITH RECURSIVE membership(id, root_id, comment_created_at) AS (
-- Each root maps to itself.
SELECT c.id, c.id AS root_id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
UNION ALL
-- Each descendant inherits its parent's root_id.
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id, MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
),
picked AS (
SELECT ts.root_id, ts.last_activity_at
FROM thread_stats ts
WHERE (
$3::boolean = FALSE
OR (ts.last_activity_at, ts.root_id) < ($4::timestamptz, $5::uuid)
)
ORDER BY ts.last_activity_at DESC, ts.root_id DESC
LIMIT $6
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id, c.revision, c.deleted_at,
p.root_id AS thread_root_id,
p.last_activity_at AS thread_last_activity_at
FROM picked p
JOIN membership m ON m.root_id = p.root_id
JOIN comment c ON c.id = m.id
ORDER BY p.last_activity_at ASC, p.root_id ASC, c.created_at ASC, c.id ASC
`
type ListRecentThreadCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
HasCursor bool `json:"has_cursor"`
BeforeAt pgtype.Timestamptz `json:"before_at"`
BeforeID pgtype.UUID `json:"before_id"`
ThreadLimit int32 `json:"thread_limit"`
}
type ListRecentThreadCommentsForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
Revision int64 `json:"revision"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ThreadRootID pgtype.UUID `json:"thread_root_id"`
ThreadLastActivityAt pgtype.Timestamptz `json:"thread_last_activity_at"`
}
// Returns the N most recently active threads (root + every descendant) rather
// than the N most recent rows. A thread's "last activity" is MAX(created_at)
// over its whole subtree; threads are ranked by (last_activity_at DESC,
// root_id DESC) and the top N are expanded.
//
// Why thread-grouped instead of row-recent: with row-recent the newest 20
// comments can come from 8 different threads — the agent sees 8 unrelated
// tails. With thread-grouped the agent sees N complete conversational arcs,
// which matches how a human reads an issue (#2340).
//
// Response ordering:
//
// threads: (thread_last_activity_at ASC, root_id ASC)
// in-thread: (created_at ASC, id ASC)
//
// So the oldest-active thread appears first and the most recently-active
// thread is at the tail, closest to "now" in an agent prompt.
//
// Cursor scrolls back through threads. When @has_cursor=TRUE only threads
// with (last_activity_at, root_id) < (@before_at, @before_id) are eligible.
// The cursor is a THREAD cursor — both values identify a thread (its last
// activity timestamp and its root comment id), not a single row.
//
// The recursive `membership` CTE labels each comment with its thread root by
// walking down from every root. It does not assume any maximum nesting depth,
// which preserves correctness even if the schema ever allows reply-of-reply
// (the agent path in TaskService.createAgentComment collapses to root today,
// but the user-facing CreateComment handler does not enforce it).
func (q *Queries) ListRecentThreadCommentsForIssue(ctx context.Context, arg ListRecentThreadCommentsForIssueParams) ([]ListRecentThreadCommentsForIssueRow, error) {
rows, err := q.db.Query(ctx, listRecentThreadCommentsForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.HasCursor,
arg.BeforeAt,
arg.BeforeID,
arg.ThreadLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentThreadCommentsForIssueRow{}
for rows.Next() {
var i ListRecentThreadCommentsForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.Revision,
&i.DeletedAt,
&i.ThreadRootID,
&i.ThreadLastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listReconcilableCommentsForIssueSince = `-- name: ListReconcilableCommentsForIssueSince :many
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type, c.created_at, c.updated_at, c.parent_id, c.workspace_id, c.resolved_at, c.resolved_by_type, c.resolved_by_id, c.source_task_id, c.quick_action_id, c.via_plugin_id, c.revision, c.recovery_settled_at, c.deleted_at, c.suppressed_agent_ids FROM comment c
WHERE c.issue_id = $1
AND (c.id = ANY($2::uuid[])
OR comment_thread_root_id(c.id) = $3::uuid)
-- A deleted comment is no longer input, even when replies keep its row.
AND c.deleted_at IS NULL
-- A comment that steered this agent's running turn belongs to that turn,
-- regardless of delivery status: failed delivery must not become an automatic
-- new run. The same comment still reconciles normally for any other agent
-- it addressed without steering.
AND NOT EXISTS (
SELECT 1 FROM task_supplement s
JOIN agent_task_queue bound ON bound.id = s.task_id
WHERE s.comment_id = c.id AND s.workspace_id = c.workspace_id
AND bound.agent_id = $4
)
-- Nor does a comment whose author chose not to start this agent for it.
AND NOT COALESCE($4::uuid = ANY(c.suppressed_agent_ids), false)
AND (
(
c.author_type IN ('member', 'agent')
AND (c.created_at > $5 OR c.id = ANY($2::uuid[]))
)
OR (
c.author_type = 'system'
AND c.type = 'progress_update'
AND c.source_task_id IS NOT NULL
AND c.id = ANY($2::uuid[])
)
)
ORDER BY c.created_at ASC, c.id ASC
`
type ListReconcilableCommentsForIssueSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
PlannedCommentIds []pgtype.UUID `json:"planned_comment_ids"`
CommentThreadID pgtype.UUID `json:"comment_thread_id"`
AgentID pgtype.UUID `json:"agent_id"`
Since pgtype.Timestamptz `json:"since"`
}
// MUL-4195 / MUL-4304 completion reconciliation: every MEMBER- or AGENT-authored
// comment on an issue created strictly after @since (the completing run's
// created_at anchor), plus every id in its planned trigger/coalesced batch.
// The one platform-authored exception is a delegated-failure recovery signal:
// author_type=system, type=progress_update, source_task_id set. Such a signal
// can be registered after a coordinator was claimed and must be replayed when
// that run completes; the handler routes it through the dedicated recovery
// path instead of generic comment/mention routing.
// Planned ids matter for retry children because their input comments predate
// the child's created_at; if one could not be embedded at claim time it still
// needs reconciliation. The handler excludes only delivered_comment_ids, then
// replays the remainder through the normal trigger pipeline oldest first.
//
// Author-type scope (MUL-4304): originally restricted to author_type = 'member'.
// That left a gap — an explicit agent→agent @mention (agent A comments
// `@agent B`) that landed while B already had a DISPATCHED task was dropped by
// the create-time enqueue path (merge only folds into a QUEUED task, so a
// dispatched target hits the merge-miss + active-task continue) and then never
// compensated here, because agent-authored comments were excluded. We now also
// return 'agent' comments so those explicit mentions can be replayed.
//
// The handler rechecks current routing and permissions. For agent authors it
// accepts explicit mentions, plus worker-to-assigned-leader replies already
// recorded in this run's planned inputs. Timestamp-only implicit agent replies
// remain excluded. Replays are scoped to the completing agent, never a fan-out.
// Ordered ASC so later comments coalesce onto the follow-up created by the first.
func (q *Queries) ListReconcilableCommentsForIssueSince(ctx context.Context, arg ListReconcilableCommentsForIssueSinceParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listReconcilableCommentsForIssueSince,
arg.IssueID,
arg.PlannedCommentIds,
arg.CommentThreadID,
arg.AgentID,
arg.Since,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootCommentsForIssue = `-- name: ListRootCommentsForIssue :many
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
ORDER BY c.created_at DESC, c.id DESC
LIMIT $3
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id, c.revision, c.deleted_at,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC
`
type ListRootCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RowLimit int32 `json:"row_limit"`
}
type ListRootCommentsForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
Revision int64 `json:"revision"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ReplyCount int32 `json:"reply_count"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
}
// Top-level comments only, in issue chronological order, each annotated with
// per-thread orientation stats: reply_count (number of descendants) and
// last_activity_at (MAX(created_at) over the whole subtree). This powers
// `comment list --roots-only` so agents can not only orient around the global
// discussion but also triage which thread to drill into (biggest / most
// recently active) before fetching any specific reply thread.
//
// `selected_roots` takes the newest @row_limit roots. The final SELECT restores
// chronological order, so the defensive cap drops the oldest roots without
// changing the wire order. The recursive `membership` walk only expands those
// selected threads rather than every thread in the issue, and labels every
// descendant with its root so the stats stay correct at any reply depth.
func (q *Queries) ListRootCommentsForIssue(ctx context.Context, arg ListRootCommentsForIssueParams) ([]ListRootCommentsForIssueRow, error) {
rows, err := q.db.Query(ctx, listRootCommentsForIssue, arg.IssueID, arg.WorkspaceID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootCommentsForIssueRow{}
for rows.Next() {
var i ListRootCommentsForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.Revision,
&i.DeletedAt,
&i.ReplyCount,
&i.LastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootCommentsSinceForIssue = `-- name: ListRootCommentsSinceForIssue :many
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
AND c.created_at > $3
ORDER BY c.created_at ASC, c.id ASC
LIMIT $4
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id, c.revision, c.deleted_at,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC
`
type ListRootCommentsSinceForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
RowLimit int32 `json:"row_limit"`
}
type ListRootCommentsSinceForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
Revision int64 `json:"revision"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
ReplyCount int32 `json:"reply_count"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
}
// Top-level comments created strictly after @since, each annotated with the
// same reply_count / last_activity_at stats as ListRootCommentsForIssue. The
// @since filter narrows which roots are returned; the stats are still computed
// over each selected thread's full subtree (so a freshly created root with no
// replies reports reply_count 0 and last_activity_at = its own created_at).
// selected_roots applies the @since + @row_limit cut up front so the recursive
// membership walk only touches the subtrees of the roots we actually return.
func (q *Queries) ListRootCommentsSinceForIssue(ctx context.Context, arg ListRootCommentsSinceForIssueParams) ([]ListRootCommentsSinceForIssueRow, error) {
rows, err := q.db.Query(ctx, listRootCommentsSinceForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.Since,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootCommentsSinceForIssueRow{}
for rows.Next() {
var i ListRootCommentsSinceForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.Revision,
&i.DeletedAt,
&i.ReplyCount,
&i.LastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listThreadCommentsForIssuePaged = `-- name: ListThreadCommentsForIssuePaged :many
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.issue_id = $2 AND c.workspace_id = $3
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
WHERE p.issue_id = $2 AND p.workspace_id = $3
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id, c.revision, c.deleted_at
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
c.source_task_id, c.quick_action_id, c.revision, c.deleted_at
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = $2 AND c.workspace_id = $3
),
reply_page AS (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id,
d.source_task_id, d.quick_action_id, d.revision, d.deleted_at
FROM descendants d
WHERE d.id NOT IN (SELECT id FROM thread_root)
AND (
$4::boolean = FALSE
OR (d.created_at, d.id) < ($5::timestamptz, $6::uuid)
)
ORDER BY d.created_at DESC, d.id DESC
LIMIT $7
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id,
source_task_id, quick_action_id, revision, deleted_at
FROM (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id,
d.source_task_id, d.quick_action_id, d.revision, d.deleted_at
FROM descendants d
JOIN thread_root tr ON d.id = tr.id
UNION ALL
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id,
source_task_id, quick_action_id, revision, deleted_at
FROM reply_page
) combined
ORDER BY created_at ASC, id ASC
`
type ListThreadCommentsForIssuePagedParams struct {
AnchorID pgtype.UUID `json:"anchor_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
HasCursor bool `json:"has_cursor"`
BeforeAt pgtype.Timestamptz `json:"before_at"`
BeforeID pgtype.UUID `json:"before_id"`
ReplyLimit int32 `json:"reply_limit"`
}
type ListThreadCommentsForIssuePagedRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
Revision int64 `json:"revision"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
// Resolves @anchor_id to its thread root, recursively expands every descendant,
// and returns the root + only the @reply_limit most recent replies (per the
// (created_at, id) composite key). When @has_cursor=TRUE only replies with
// (created_at, id) < (@before_at, @before_id) are eligible — that is the
// cursor for scrolling *within* a thread.
//
// Root is unconditional: it is included regardless of @reply_limit (even 0)
// and regardless of the cursor. A reader landing on a long thread needs the
// root for the "what is this thread about" context, even if every reply has
// been paginated past.
//
// Reply selection happens DESC (newest replies first) so the cursor walks
// toward older replies; the outer SELECT then re-sorts the combined output
// ASC so the body stays chronological (oldest → newest), matching every
// other comment list path.
func (q *Queries) ListThreadCommentsForIssuePaged(ctx context.Context, arg ListThreadCommentsForIssuePagedParams) ([]ListThreadCommentsForIssuePagedRow, error) {
rows, err := q.db.Query(ctx, listThreadCommentsForIssuePaged,
arg.AnchorID,
arg.IssueID,
arg.WorkspaceID,
arg.HasCursor,
arg.BeforeAt,
arg.BeforeID,
arg.ReplyLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListThreadCommentsForIssuePagedRow{}
for rows.Next() {
var i ListThreadCommentsForIssuePagedRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.Revision,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockCommentAncestorPath = `-- name: LockCommentAncestorPath :many
WITH RECURSIVE ancestor_ids AS (
SELECT c.id, c.parent_id, 1::integer AS depth, ARRAY[c.id]::uuid[] AS visited_ids
FROM comment c
WHERE c.id = $1
AND c.workspace_id = $2
AND c.issue_id = $3
UNION ALL
SELECT parent.id, parent.parent_id, path.depth + 1, path.visited_ids || parent.id
FROM ancestor_ids path
JOIN comment parent ON parent.id = path.parent_id
WHERE parent.workspace_id = $2
AND parent.issue_id = $3
AND path.depth <= 256
AND NOT parent.id = ANY(path.visited_ids)
)
SELECT c.id
FROM comment c
JOIN ancestor_ids path ON path.id = c.id
ORDER BY c.id
FOR UPDATE OF c
`
type LockCommentAncestorPathParams struct {
CommentID pgtype.UUID `json:"comment_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
}
func (q *Queries) LockCommentAncestorPath(ctx context.Context, arg LockCommentAncestorPathParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, lockCommentAncestorPath, arg.CommentID, arg.WorkspaceID, arg.IssueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockCommentForDelete = `-- name: LockCommentForDelete :one
WITH locked_issue AS MATERIALIZED (
SELECT issue.id
FROM issue
JOIN comment ON comment.issue_id = issue.id
AND comment.workspace_id = issue.workspace_id
WHERE comment.id = $1 AND comment.workspace_id = $2
FOR UPDATE OF issue
), issue_fence AS MATERIALIZED (
-- The consumed locked_count below is the ordering fence: the issue lock is
-- acquired before the comment lock. MATERIALIZED only prevents
-- folding/re-evaluation and is not, by itself, a lock-order guarantee.
SELECT count(*) AS locked_count FROM locked_issue
)
SELECT comment.id, comment.issue_id, comment.author_type, comment.author_id, comment.content, comment.type, comment.created_at, comment.updated_at, comment.parent_id, comment.workspace_id, comment.resolved_at, comment.resolved_by_type, comment.resolved_by_id, comment.source_task_id, comment.quick_action_id, comment.via_plugin_id, comment.revision, comment.recovery_settled_at, comment.deleted_at, comment.suppressed_agent_ids
FROM comment
CROSS JOIN issue_fence
WHERE comment.id = $1 AND comment.workspace_id = $2
AND comment.deleted_at IS NULL
AND issue_fence.locked_count >= 0
FOR UPDATE OF comment
`
type LockCommentForDeleteParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// First statement of the comment delete transaction (#8296). Defense-in-depth:
// workspace_id is a SQL-layer tenant guard. See DeleteIssue.
//
// It takes the aggregate owner's lock before the comment's, so deleting cannot
// deadlock with issue teardown (which takes the same issue -> comment order).
// The issue lock is also what freezes the thread: every comment insert goes
// through CreateComment, whose first step updates this issue row, so no reply
// can be added to the issue until the delete transaction ends. The caller's
// later statements start from a fresh snapshot taken after the locks, which is
// what makes their reply check exact.
//
// A tombstone is excluded: deleting an already-deleted comment finds nothing.
func (q *Queries) LockCommentForDelete(ctx context.Context, arg LockCommentForDeleteParams) (Comment, error) {
row := q.db.QueryRow(ctx, lockCommentForDelete, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const lockLiveComment = `-- name: LockLiveComment :one
WITH locked_issue AS MATERIALIZED (
SELECT issue.id
FROM issue
JOIN comment ON comment.issue_id = issue.id
AND comment.workspace_id = issue.workspace_id
WHERE comment.id = $1 AND comment.workspace_id = $2
FOR NO KEY UPDATE OF issue
), issue_fence AS MATERIALIZED (
SELECT count(*) AS locked_count FROM locked_issue
)
SELECT comment.id, comment.issue_id, comment.author_type, comment.author_id, comment.content, comment.type, comment.created_at, comment.updated_at, comment.parent_id, comment.workspace_id, comment.resolved_at, comment.resolved_by_type, comment.resolved_by_id, comment.source_task_id, comment.quick_action_id, comment.via_plugin_id, comment.revision, comment.recovery_settled_at, comment.deleted_at, comment.suppressed_agent_ids
FROM comment
CROSS JOIN issue_fence
WHERE comment.id = $1 AND comment.workspace_id = $2
AND comment.deleted_at IS NULL
AND issue_fence.locked_count >= 0
FOR NO KEY UPDATE OF comment
`
type LockLiveCommentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Locks a live comment ahead of a write to one of its children — reactions and
// attachments — in the order every comment mutation shares: issue, then
// comment, then child. A comment deleted before or while this waits reads as
// absent (the row lock re-checks deleted_at), so no child can land on its
// tombstone. FOR NO KEY UPDATE conflicts with the delete transaction's FOR
// UPDATE but not with a reply's FOR KEY SHARE, so it never blocks replies.
func (q *Queries) LockLiveComment(ctx context.Context, arg LockLiveCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, lockLiveComment, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const resolveComment = `-- name: ResolveComment :one
UPDATE comment SET
resolved_at = COALESCE(resolved_at, now()),
resolved_by_type = COALESCE(resolved_by_type, $2),
resolved_by_id = COALESCE(resolved_by_id, $3),
revision = revision + CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END,
updated_at = CASE WHEN resolved_at IS NULL THEN now() ELSE updated_at END
WHERE id = $1
AND deleted_at IS NULL
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
`
type ResolveCommentParams struct {
ID pgtype.UUID `json:"id"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
}
// Idempotent: re-resolving keeps the original resolved_at + resolver. Always
// returns the row so the handler can surface the canonical state — unless the
// comment has been deleted, whose tombstone cannot be a resolution.
func (q *Queries) ResolveComment(ctx context.Context, arg ResolveCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, resolveComment, arg.ID, arg.ResolvedByType, arg.ResolvedByID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const tombstoneComment = `-- name: TombstoneComment :one
UPDATE comment SET
content = '',
deleted_at = now(),
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
revision = revision + 1,
updated_at = now()
WHERE id = $1 AND workspace_id = $2
AND deleted_at IS NULL
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
`
type TombstoneCommentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Deletes a comment that still has replies by clearing it in place. The row,
// its id and its parent_id stay so every reply keeps its direct parent; the
// body goes, and so does any resolution, since a deleted comment cannot be a
// thread's conclusion. The caller removes attachments and reactions in the
// same transaction.
func (q *Queries) TombstoneComment(ctx context.Context, arg TombstoneCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, tombstoneComment, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const touchIssueForCommentDelete = `-- name: TouchIssueForCommentDelete :one
UPDATE issue
SET revision = revision + 1,
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now())
WHERE id = $1 AND workspace_id = $2
RETURNING revision
`
type TouchIssueForCommentDeleteParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// A delete counts as activity on its issue, like CreateComment and
// UpdateComment. Runs inside the delete transaction after the comment rows
// changed, so a delete that lost its race never touches the issue.
func (q *Queries) TouchIssueForCommentDelete(ctx context.Context, arg TouchIssueForCommentDeleteParams) (int64, error) {
row := q.db.QueryRow(ctx, touchIssueForCommentDelete, arg.IssueID, arg.WorkspaceID)
var revision int64
err := row.Scan(&revision)
return revision, err
}
const unresolveComment = `-- name: UnresolveComment :one
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
revision = revision + CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END,
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id, source_task_id, quick_action_id, via_plugin_id, revision, recovery_settled_at, deleted_at, suppressed_agent_ids
`
// Idempotent: a no-op clear (already unresolved) just returns the row.
func (q *Queries) UnresolveComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
row := q.db.QueryRow(ctx, unresolveComment, id)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.RecoverySettledAt,
&i.DeletedAt,
&i.SuppressedAgentIds,
)
return i, err
}
const updateComment = `-- name: UpdateComment :one
WITH locked_issue AS MATERIALIZED (
-- Keep the global issue -> child lock order used by issue teardown. The
-- aggregate below still yields one row when the parent was concurrently
-- deleted, preserving best-effort edits of an orphaned comment.
SELECT issue.id
FROM issue
JOIN comment ON comment.issue_id = issue.id
AND comment.workspace_id = issue.workspace_id
WHERE comment.id = $1
FOR UPDATE OF issue
), issue_fence AS MATERIALIZED (
-- The aggregate always emits one row. Consuming locked_count in target's
-- tautological predicate creates a real data dependency: locked_issue must
-- acquire the owner lock before target can lock the comment. MATERIALIZED
-- prevents folding/re-evaluation; it does not itself establish lock order.
SELECT count(*) AS locked_count FROM locked_issue
), target AS MATERIALIZED (
SELECT comment.id, comment.issue_id, comment.author_type, comment.author_id, comment.content, comment.type, comment.created_at, comment.updated_at, comment.parent_id, comment.workspace_id, comment.resolved_at, comment.resolved_by_type, comment.resolved_by_id, comment.source_task_id, comment.quick_action_id, comment.via_plugin_id, comment.revision, comment.recovery_settled_at, comment.deleted_at, comment.suppressed_agent_ids,
ROW(comment.content, comment.source_task_id) IS DISTINCT FROM
ROW($2, $3::uuid) AS did_change
FROM comment
CROSS JOIN issue_fence
WHERE comment.id = $1
AND issue_fence.locked_count >= 0
-- A deleted comment's tombstone is not editable.
AND comment.deleted_at IS NULL
AND ($4::bigint IS NULL OR revision = $4::bigint)
AND (
$5::text IS NULL
OR content IS NOT DISTINCT FROM $5::text
OR content IS NOT DISTINCT FROM $2
)
FOR UPDATE OF comment
), updated_comment AS (
UPDATE comment SET
content = $2,
source_task_id = $3::uuid,
-- New text re-decides which agents it must not start, under the same
-- revision check as the text itself.
suppressed_agent_ids = CASE WHEN target.content IS DISTINCT FROM $2
THEN $6::uuid[]
ELSE comment.suppressed_agent_ids END,
revision = comment.revision + CASE WHEN target.did_change THEN 1 ELSE 0 END,
updated_at = CASE WHEN target.did_change THEN now() ELSE comment.updated_at END
FROM target
WHERE comment.id = target.id
RETURNING comment.id, comment.issue_id, comment.author_type, comment.author_id,
comment.content, comment.type, comment.created_at, comment.updated_at,
comment.parent_id, comment.workspace_id, comment.resolved_at,
comment.resolved_by_type, comment.resolved_by_id, comment.source_task_id,
comment.quick_action_id, comment.via_plugin_id, comment.revision,
comment.deleted_at, target.did_change
), touched_issue AS (
UPDATE issue
SET revision = issue.revision + 1,
last_activity_at = GREATEST(COALESCE(issue.last_activity_at, issue.updated_at), now())
FROM updated_comment
WHERE updated_comment.did_change
AND issue.id = updated_comment.issue_id
AND issue.workspace_id = updated_comment.workspace_id
RETURNING issue.id, issue.revision
)
SELECT updated_comment.id, updated_comment.issue_id, updated_comment.author_type,
updated_comment.author_id, updated_comment.content, updated_comment.type,
updated_comment.created_at, updated_comment.updated_at, updated_comment.parent_id,
updated_comment.workspace_id, updated_comment.resolved_at,
updated_comment.resolved_by_type, updated_comment.resolved_by_id,
updated_comment.source_task_id, updated_comment.quick_action_id,
updated_comment.via_plugin_id, updated_comment.revision,
updated_comment.deleted_at,
COALESCE((SELECT revision FROM touched_issue), 0)::bigint AS issue_revision
FROM updated_comment
`
type UpdateCommentParams struct {
ID pgtype.UUID `json:"id"`
Content string `json:"content"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
ExpectedRevision pgtype.Int8 `json:"expected_revision"`
ContentBase pgtype.Text `json:"content_base"`
SuppressedAgentIds []pgtype.UUID `json:"suppressed_agent_ids"`
}
type UpdateCommentRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
SourceTaskID pgtype.UUID `json:"source_task_id"`
QuickActionID pgtype.UUID `json:"quick_action_id"`
ViaPluginID pgtype.UUID `json:"via_plugin_id"`
Revision int64 `json:"revision"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
IssueRevision int64 `json:"issue_revision"`
}
func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (UpdateCommentRow, error) {
row := q.db.QueryRow(ctx, updateComment,
arg.ID,
arg.Content,
arg.SourceTaskID,
arg.ExpectedRevision,
arg.ContentBase,
arg.SuppressedAgentIds,
)
var i UpdateCommentRow
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.SourceTaskID,
&i.QuickActionID,
&i.ViaPluginID,
&i.Revision,
&i.DeletedAt,
&i.IssueRevision,
)
return i, err
}