mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(server): retire settled delegated-failure recoveries from the outbox scan ListPendingDelegatedFailureRecoveries had no indexable notion of a finished recovery. Its partial index (343) selected every recovery comment ever written, so each sweeper tick re-proved through four joins and two NOT EXISTS subqueries that history was already handled. LIMIT bounds the rows RETURNED, never the rows CHECKED, so a tick that found nothing still grew more expensive as history accumulated. Add comment.recovery_settled_at and a partial index that holds only unsettled recovery comments, and write the marker inside the transaction that makes a task terminal. Every other exclusion the scan applies is reversible — an issue can leave 'done', a cancelled retry can be superseded — so none of them can be frozen into an index predicate. A delivery receipt on a terminal task cannot be taken back, which is what makes it safe to record as durable state. The marker is deliberately written only once the task is terminal. SetTaskDeliveredCommentIDs replaces rather than appends a receipt while a task is still dispatched, so a reclaim by a differently-capable daemon can drop an id it will not deliver; settling at receipt-write time would freeze that legitimate transient window into a permanently lost recovery. Exhaustion is the one terminal outcome whose receipt lands on a row that may still be running, so it retires the comment directly in the same transaction. An unmarked row is the safe direction: it is scanned exactly as it was before this column existed. That is what makes the rolling-deploy window harmless — an older replica that writes a receipt without the marker only leaves work for the backfill. Co-authored-by: multica-agent <[email protected]> * feat(server): backfill delegated-failure recoveries settled before the marker The marker only covers tasks that reach a terminal status after the writing code ships. History written before that keeps matching the unsettled index predicate and keeps being re-checked on every sweeper tick, which is the cost this change set exists to remove. The walk retires a candidate only when a terminal task already recorded it in delivered_comment_ids — the same monotonic condition the runtime path uses. Its keyset watermark advances over every candidate EXAMINED rather than every candidate marked: a recovery that is genuinely still pending must be left alone, and keying on updated rows would hand back the same watermark forever and never terminate. Separate from migrations and startup, in the shape the other backfill commands already use, so a large comment table cannot block a deploy. Running it is a performance recovery step, not a correctness step — until it finishes the scan behaves exactly as it does today. Co-authored-by: multica-agent <[email protected]> * fix(server): close the review gaps in the settled-recovery backfill and paths Three fixes from review. 1. The backfill reproduced the bug it was written to fix. Its keyset ordered by id while idx_comment_delegated_failure_unsettled is (created_at, id), so the candidate scan read every unsettled row and sorted it to return LIMIT of them; and the receipt test ran a per-candidate EXISTS over agent_task_queue, the largest table in the database. The keyset now matches the index, and the receipt lookup is inverted into one agent_task_queue read per BATCH that hash-joins delivered ids back against the candidates. Measured on 500k comments / 200k tasks / 50k unsettled recoveries: 17,393ms and 816,963 temp blocks before, 235ms and no temp spill after. The candidate scan goes from reading 50,000 rows to reading the 1,000 it needs. Total walk time is flat across batch size (50x228ms, 10x1016ms, 5x2199ms — all ~11s), so batch size only trades how long one statement holds row locks. 1000 stays, now for a measured reason rather than a guessed one. 2. Bulk cancellation paths could strand a delivered receipt permanently unsettled, which lets the new index regrow the history it exists to remove. Manual rerun's pending-slot clear and agent archive were the two named in review; auditing the rest found comment-edit cancellation, both chat-session cancellations, workspace revoke and runtime teardown in the same position. All now settle — inside the caller's transaction where one exists, and through CaptureCancelledTasks for bulk paths that finalize outside one. The invariant is stated on the exported helper so the next such path has somewhere to read it. 3. --max-batches could not actually resume. The watermark lived only in the process, so a bounded rerun restarted at the front of the index — which is precisely where the rows this walk never settles live, so repeated bounded runs would rescan the same prefix and never reach the settleable history behind it. Adds --checkpoint-file (atomic replace, read on start) and explicit --after-created-at/--after-id, and logs the resume cursor on every exit path including SIGTERM. Co-authored-by: multica-agent <[email protected]> * fix(server): make delegated-failure settlement terminal-checked and atomic Two fixes from review. 1. The settlement SQL trusted its caller. "A delivery receipt is only immutable once the task is terminal" is the safety premise of the whole design, but the exported helper enforced nothing: passing a dispatched or running task would settle a receipt a reclaiming daemon can still replace, and the marker is monotonic, so nothing could undo it. The predicate now lives in the SQL, so a non-terminal task updates no rows instead of destroying the obligation. 2. Bulk settlement was post-commit best-effort, and the comment claiming the sweeper would replay a failed one was simply wrong: the covering task is terminal and already holds the receipt, so ListPendingDelegatedFailureRecoveries excludes the comment. It would neither replay nor settle — it would sit in the partial index forever, restoring the unbounded scan this change set removes. Every terminal write now commits with its settlement. CancelTasksForIssue / ForAgent / ByTriggerComment and the rerun pending-slot clear run in a transaction; CancelQueuedChatTasks settles inside the one it already had; the sweeper and daemon-recovery bulk fails move behind TaskService wrappers that own the transaction; agent archive gets CancelTasksForArchivedAgent, which cancels and settles atomically while still skipping the per-task broadcast the archive event makes redundant. BroadcastCancelledTasks, CaptureCancelledTasks and HandleFailedTasks run after their caller committed, so they no longer settle at all — a failure there could be neither rolled back nor repaired. The best-effort helper is deleted rather than left available; every remaining call site passes a qtx. Tests: a dispatched/running task with a receipt is not settled (fails without the SQL predicate), a bulk cancel whose settlement fails rolls the cancel back, and the archive path settles through its atomic entry point. Co-authored-by: multica-agent <[email protected]> * docs(server): correct settlement invariant comment and drop leaked test function The SettleDeliveredDelegatedFailureRecoveries doc still described the pre-fix contract — settlement reachable through CaptureCancelledTasks or a post-commit best-effort call — which is exactly the shape the atomicity fix removed. Restate the rule as it now stands (same qtx as the terminal write, never from a post-commit helper) so the stale text cannot guide a future change back into stranding rows in the partial index. TestBulkCancelRollsBackWhenSettlementFails dropped its trigger but left reject_recovery_settlement() behind in the shared test database; drop the function too. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Eve <[email protected]>
2035 lines
72 KiB
Go
2035 lines
72 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
|
|
`
|
|
|
|
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,
|
|
)
|
|
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
|
|
`
|
|
|
|
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,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
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 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, id)
|
|
SELECT ti.id, ti.workspace_id, $3, $4, $5, $6, $7, $8, $9, $10, COALESCE($11::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
|
|
)
|
|
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, 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"`
|
|
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"`
|
|
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.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.IssueRevision,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteComment = `-- name: DeleteComment :one
|
|
WITH locked_issue AS MATERIALIZED (
|
|
-- Lock the aggregate owner before its child so this cannot deadlock with
|
|
-- issue teardown (which takes the same issue -> comment order).
|
|
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 DELETE can lock the comment. MATERIALIZED only prevents
|
|
-- folding/re-evaluation and is not, by itself, a lock-order guarantee.
|
|
SELECT count(*) AS locked_count FROM locked_issue
|
|
), deleted_comment AS (
|
|
DELETE FROM comment
|
|
USING issue_fence
|
|
WHERE comment.id = $1 AND comment.workspace_id = $2
|
|
AND issue_fence.locked_count >= 0
|
|
RETURNING issue_id, workspace_id
|
|
), touched_issue AS (
|
|
UPDATE issue
|
|
SET revision = issue.revision + 1,
|
|
last_activity_at = GREATEST(COALESCE(issue.last_activity_at, issue.updated_at), now())
|
|
FROM deleted_comment
|
|
WHERE issue.id = deleted_comment.issue_id
|
|
AND issue.workspace_id = deleted_comment.workspace_id
|
|
RETURNING issue.id, issue.revision
|
|
)
|
|
SELECT EXISTS(SELECT 1 FROM deleted_comment) AS changed,
|
|
COALESCE((SELECT revision FROM touched_issue), 0)::bigint AS issue_revision
|
|
`
|
|
|
|
type DeleteCommentParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
type DeleteCommentRow struct {
|
|
Changed bool `json:"changed"`
|
|
IssueRevision int64 `json:"issue_revision"`
|
|
}
|
|
|
|
// Defense-in-depth: workspace_id is a SQL-layer tenant guard. See DeleteIssue.
|
|
func (q *Queries) DeleteComment(ctx context.Context, arg DeleteCommentParams) (DeleteCommentRow, error) {
|
|
row := q.db.QueryRow(ctx, deleteComment, arg.ID, arg.WorkspaceID)
|
|
var i DeleteCommentRow
|
|
err := row.Scan(&i.Changed, &i.IssueRevision)
|
|
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 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,
|
|
)
|
|
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 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,
|
|
)
|
|
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 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,
|
|
)
|
|
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 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,
|
|
)
|
|
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 FROM comment
|
|
WHERE issue_id = $1
|
|
AND author_type = 'member'
|
|
AND created_at > $2
|
|
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,
|
|
)
|
|
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 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,
|
|
)
|
|
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
|
|
) 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 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,
|
|
); 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, 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,
|
|
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,
|
|
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"`
|
|
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.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
|
|
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
|
|
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
|
|
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"`
|
|
}
|
|
|
|
// 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,
|
|
); 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 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,
|
|
); 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 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 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,
|
|
); 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 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,
|
|
); 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,
|
|
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"`
|
|
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.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 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 FROM comment
|
|
WHERE issue_id = $1
|
|
AND (
|
|
(
|
|
author_type IN ('member', 'agent')
|
|
AND (created_at > $2 OR id = ANY($3::uuid[]))
|
|
)
|
|
OR (
|
|
author_type = 'system'
|
|
AND type = 'progress_update'
|
|
AND source_task_id IS NOT NULL
|
|
AND id = ANY($3::uuid[])
|
|
)
|
|
)
|
|
ORDER BY created_at ASC, id ASC
|
|
`
|
|
|
|
type ListReconcilableCommentsForIssueSinceParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
PlannedCommentIds []pgtype.UUID `json:"planned_comment_ids"`
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// This does NOT reopen the anti-loop guarantees the member-only filter was
|
|
// protecting. The reconcile pass runs each returned comment through
|
|
// computeCommentAgentTriggers under its OWN author_type, and for an agent author
|
|
// it then keeps ONLY explicit @agent/@squad mention triggers
|
|
// (keepExplicitMentionTriggers) — the assigned-squad-leader fallback and all
|
|
// other conversational routing are dropped, so a plain agent reply /
|
|
// acknowledgement yields nothing regardless of issue assignment. The reconcile
|
|
// pass further keeps only triggers routing to the agent that just completed, so
|
|
// an agent comment can never fan out to an unrelated agent. Ordered ASC so
|
|
// replaying in order lets 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.Since, arg.PlannedCommentIds)
|
|
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,
|
|
); 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,
|
|
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"`
|
|
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.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,
|
|
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"`
|
|
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.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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"`
|
|
}
|
|
|
|
// 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,
|
|
); 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 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
|
|
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
|
|
`
|
|
|
|
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.
|
|
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,
|
|
)
|
|
return i, 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
|
|
`
|
|
|
|
// 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,
|
|
)
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
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,
|
|
)
|
|
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.IssueRevision,
|
|
)
|
|
return i, err
|
|
}
|