From 7bd81b288e81da7d6d5d36147814413fa17fc97f Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 27 Jul 2026 13:10:06 +0100 Subject: [PATCH 1/3] feat(carve S5): orchestration DAG core, row store, and the channel abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dormant foundation for parent/sub-issue orchestration: pure graph logic, the DynamoDB row store, and a surface-agnostic feedback layer. Nothing here is instantiated in a stack yet — the executor and the stack wiring land later, so this slice is synth-and-unit-test only and changes no deployed behaviour. Graph core (pure, no I/O): - orchestration-dag: validate a `depends_on` graph (duplicate id, dangling edge, cycle) and return its topological layering via Kahn's algorithm. The layers are what a reconciler releases children from, in dependency order. - orchestration-graph-source: the seam between "where the graph came from" and the executor. Three tiers — a tracker that has native sub-issues (read it), a caller that supplies the DAG declaratively (CLI/API, or a planner), or a structureless trigger (single task). - orchestration-integration-node: when a graph fans out to several leaves, append a synthetic node depending on all of them so there is one combined artifact instead of N unrelated PRs. - orchestration-epic-tip: where a newly-added, dependency-less node stacks — the leaf frontier, so it inherits the epic's accumulated unmerged work rather than branching off the default branch. - orchestration-base-branch: pick a child's base branch from its predecessors. Row store and table: - OrchestrationTable construct: orchestration_id (PK) + sub_issue_id (SK), with sparse GSIs to resolve a child back from its task id or its head branch. - orchestration-store: the read/write surface over those rows, including the idempotency markers that keep duplicate webhook deliveries from double-acting. - orchestration-log-events: structured events for a run, plus the scheduled sweep's backstop. - linear-subissue-fetch: read a parent's children and their `blocks` relations into a DAG. Fails loud rather than silently truncating an over-size epic. Channel abstraction: - orchestration-channel: the surface-agnostic interface — required operations every channel must implement, optional ones gated by declared capability, so the engine never branches on which tracker it is talking to. - orchestration-channel-{linear,jira,slack} + factory: per-surface adapters selected from the stored row. - orchestration-comment-trigger: parse a mention comment into a command, ignoring the bot's own comments. - iteration-reply: one maturing threaded reply per iteration rather than a stream of new comments; the two async writers of a reply converge instead of overwriting each other. --- cdk/src/constructs/orchestration-table.ts | 143 +++ cdk/src/handlers/shared/iteration-reply.ts | 342 ++++++ cdk/src/handlers/shared/linear-feedback.ts | 909 +++++++++++++- .../handlers/shared/linear-issue-lookup.ts | 51 +- .../handlers/shared/linear-subissue-fetch.ts | 331 ++++++ .../shared/orchestration-base-branch.ts | 93 ++ .../shared/orchestration-channel-factory.ts | 147 +++ .../shared/orchestration-channel-jira.ts | 90 ++ .../shared/orchestration-channel-linear.ts | 190 +++ .../shared/orchestration-channel-slack.ts | 275 +++++ .../handlers/shared/orchestration-channel.ts | 271 +++++ .../shared/orchestration-comment-trigger.ts | 394 +++++++ cdk/src/handlers/shared/orchestration-dag.ts | 192 +++ .../handlers/shared/orchestration-epic-tip.ts | 94 ++ .../shared/orchestration-graph-source.ts | 102 ++ .../shared/orchestration-integration-node.ts | 97 ++ .../shared/orchestration-log-events.ts | 96 ++ .../handlers/shared/orchestration-store.ts | 954 +++++++++++++++ cdk/src/handlers/shared/slack-api.ts | 42 + .../constructs/orchestration-table.test.ts | 154 +++ .../handlers/shared/iteration-reply.test.ts | 315 +++++ .../handlers/shared/linear-feedback.test.ts | 867 +++++++++++++- .../shared/linear-issue-lookup.test.ts | 49 +- .../shared/linear-subissue-fetch.test.ts | 215 ++++ .../shared/orchestration-base-branch.test.ts | 84 ++ .../orchestration-channel-factory.test.ts | 116 ++ .../orchestration-channel-slack.test.ts | 213 ++++ .../shared/orchestration-channel.test.ts | 275 +++++ .../orchestration-comment-trigger.test.ts | 210 ++++ .../handlers/shared/orchestration-dag.test.ts | 148 +++ .../shared/orchestration-epic-tip.test.ts | 66 ++ .../shared/orchestration-graph-source.test.ts | 104 ++ .../orchestration-integration-node.test.ts | 92 ++ .../shared/orchestration-store.test.ts | 1041 +++++++++++++++++ 34 files changed, 8734 insertions(+), 28 deletions(-) create mode 100644 cdk/src/constructs/orchestration-table.ts create mode 100644 cdk/src/handlers/shared/iteration-reply.ts create mode 100644 cdk/src/handlers/shared/linear-subissue-fetch.ts create mode 100644 cdk/src/handlers/shared/orchestration-base-branch.ts create mode 100644 cdk/src/handlers/shared/orchestration-channel-factory.ts create mode 100644 cdk/src/handlers/shared/orchestration-channel-jira.ts create mode 100644 cdk/src/handlers/shared/orchestration-channel-linear.ts create mode 100644 cdk/src/handlers/shared/orchestration-channel-slack.ts create mode 100644 cdk/src/handlers/shared/orchestration-channel.ts create mode 100644 cdk/src/handlers/shared/orchestration-comment-trigger.ts create mode 100644 cdk/src/handlers/shared/orchestration-dag.ts create mode 100644 cdk/src/handlers/shared/orchestration-epic-tip.ts create mode 100644 cdk/src/handlers/shared/orchestration-graph-source.ts create mode 100644 cdk/src/handlers/shared/orchestration-integration-node.ts create mode 100644 cdk/src/handlers/shared/orchestration-log-events.ts create mode 100644 cdk/src/handlers/shared/orchestration-store.ts create mode 100644 cdk/test/constructs/orchestration-table.test.ts create mode 100644 cdk/test/handlers/shared/iteration-reply.test.ts create mode 100644 cdk/test/handlers/shared/linear-subissue-fetch.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-base-branch.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-channel-factory.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-channel-slack.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-channel.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-comment-trigger.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-dag.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-epic-tip.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-graph-source.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-integration-node.test.ts create mode 100644 cdk/test/handlers/shared/orchestration-store.test.ts diff --git a/cdk/src/constructs/orchestration-table.ts b/cdk/src/constructs/orchestration-table.ts new file mode 100644 index 000000000..ba20a07ad --- /dev/null +++ b/cdk/src/constructs/orchestration-table.ts @@ -0,0 +1,143 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for OrchestrationTable construct. + */ +export interface OrchestrationTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table holding the parent/sub-issue dependency graph (DAG) + * for sub-issue orchestration. + * + * One orchestration = one labeled Linear parent issue with sub-issues. + * Each child sub-issue is a row; the reconciler walks the rows to find + * children whose predecessors are all terminal-success and releases them + * via ``createTaskCore``. + * + * Schema: orchestration_id (PK), sub_issue_id (SK). + * + * Per-child row fields (written when the graph is discovered): + * - linear_sub_issue_id — the Linear sub-issue UUID this row tracks + * - child_task_id — the platform task_id created for this child (absent + * until the child is released by the reconciler) + * - depends_on — list of ``sub_issue_id``s that must reach + * terminal-success before this child may start + * - child_status — orchestration-local lifecycle marker (e.g. + * ``blocked`` | ``released`` | ``succeeded`` | ``failed`` | ``skipped``) + * - base_branch — the predecessor branch this child stacks on (ADR-001 + * stacked PRs); ``main`` for root children + * - parent_linear_issue_id, linear_workspace_id, repo — provenance + * + * GSI: + * - ChildTaskIndex (PK: child_task_id) — the reconciler receives a + * child terminal-state event keyed by ``task_id`` and must resolve + * which orchestration + child row it belongs to. Sparse: only rows + * whose child has been released carry ``child_task_id``. + * - ChildBranchIndex (PK: child_branch_name) — the re-stack path receives + * a GitHub ``pull_request`` event keyed by head branch and must resolve + * which orchestration child opened that branch, so it can re-stack the + * child's dependents when its branch changes. Sparse: only released + * children carry ``child_branch_name``. + * + * NOTE: this construct is introduced but not yet instantiated in any + * stack — graph discovery and the reconciler wire it in. Synth-only for + * now keeps this foundational change deploy-safe. + */ +export class OrchestrationTable extends Construct { + /** + * GSI name for resolving a child ``task_id`` back to its + * orchestration + sub-issue row. + * PK: child_task_id. Sparse — only released children are projected. + */ + public static readonly CHILD_TASK_INDEX = 'ChildTaskIndex'; + + /** + * GSI name for resolving a child's head branch back to its + * orchestration + sub-issue row (used by the re-stack path). + * PK: child_branch_name. Sparse — only released children are projected. + */ + public static readonly CHILD_BRANCH_INDEX = 'ChildBranchIndex'; + + /** + * The underlying DynamoDB table. Use this to grant access or read the table name. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: OrchestrationTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'orchestration_id', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'sub_issue_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + // GSI: resolve a released child's task_id back to its orchestration row. + // Sparse — rows without child_task_id (not yet released) are not projected. + this.table.addGlobalSecondaryIndex({ + indexName: OrchestrationTable.CHILD_TASK_INDEX, + partitionKey: { name: 'child_task_id', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // GSI: resolve a released child's head branch back to its orchestration + // row, so the re-stack path can find it. Sparse — rows without + // child_branch_name (not yet released) are not projected. + this.table.addGlobalSecondaryIndex({ + indexName: OrchestrationTable.CHILD_BRANCH_INDEX, + partitionKey: { name: 'child_branch_name', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/handlers/shared/iteration-reply.ts b/cdk/src/handlers/shared/iteration-reply.ts new file mode 100644 index 000000000..96870dffb --- /dev/null +++ b/cdk/src/handlers/shared/iteration-reply.ts @@ -0,0 +1,342 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure renderer for the success reply to an ``@bgagent`` comment-iteration. + * Its job is to tell an EDIT apart from a QUESTION so the reply doesn't claim an + * update that never happened. + * + * Background: every ``@bgagent`` comment on a PR-bearing issue spawns a + * ``coding/pr-iteration-v1`` task. When that task completes + builds, the + * platform replied "✅ Updated — PR #N" UNCONDITIONALLY — even when the comment + * was a QUESTION ("where is the login page?") and the agent made no commit. That + * read as a false success: a "✅ Updated" with nothing updated and the question + * unanswered. + * + * The agent now persists ``code_changed`` (did the branch HEAD advance?) and, + * on a no-change run, ``answer_text`` (its reply to the question). This renderer + * branches on that: + * - code changed (or unknown — back-compat) → "✅ Updated — PR #N." (as before) + * - no code changed → "💬 " (no false ✅) + * + * Pure + no I/O so both settle paths (the standalone fanout reply and the + * orchestration reconciler) render identically and it is unit-testable. + */ + +/** + * Max chars of the agent's answer surfaced inline before truncation. Matches the + * agent's own persist cap (``task_state.py`` stores ``answer_text[:2000]``) so the + * renderer never silently drops chars the agent already bounded — the agent is the + * single truncator. (``failureReason`` shares this cap for a long sanitized error.) + */ +const MAX_ANSWER_CHARS = 2000; + +/** + * Below this elapsed floor the ``working`` reply shows no "(N elapsed)" + * clause — a freshly-acked task reads as a clean "🔄 Working…" and only grows + * the liveness suffix once a run is genuinely long enough to look silent. + */ +const HEARTBEAT_ELAPSED_FLOOR_S = 90; + +/** Max chars of the agent's latest-progress hint shown on the working line. */ +const PROGRESS_NOTE_MAX = 80; + +/** + * Liveness suffix for the ``working`` state: a short italic line carrying + * elapsed time (+ an optional sanitized progress note) so a long-running task + * isn't a silent black box. Without it a slow run showed nothing at all between + * the 👀 ack and the final ❌ — 22 minutes of silence in one observed run. + * Returns '' for a just-started task (< {@link HEARTBEAT_ELAPSED_FLOOR_S}) + * so the first ack stays clean. Pure. + */ +function workingLivenessSuffix( + elapsedS: number | null | undefined, + progressNote: string | undefined, +): string { + const e = dur(elapsedS); + // Only show the clause once the run is long enough to read as "is it alive?". + const showElapsed = + typeof elapsedS === 'number' && Number.isFinite(elapsedS) && elapsedS >= HEARTBEAT_ELAPSED_FLOOR_S; + const note = (progressNote ?? '').replace(/\s+/g, ' ').trim(); + const parts: string[] = []; + if (showElapsed && e) parts.push(`${e} elapsed`); + if (note) parts.push(truncate(note, PROGRESS_NOTE_MAX)); + return parts.length ? `_${parts.join(' · ')}_` : ''; +} + +/** + * The maturing iteration reply. One threaded reply per + * ``@bgagent`` comment that EDITS IN PLACE through these states instead of + * posting ~5 separate top-level comments per round. Same idea as the live status + * panel on a parent epic. + * + * - ``on_it`` — posted synchronously at trigger time (kills the silence). + * - ``working`` — the agent opened/updated the PR (pr_created milestone). + * - ``updated`` — terminal success WITH a commit → the ✅ + cost + total. + * - ``answered`` — terminal success, NO commit (a question) → 💬 + the answer. + * - ``failed`` — terminal failure. + */ +export type IterationState = 'on_it' | 'working' | 'updated' | 'answered' | 'failed'; + +export interface MaturingReplyInput { + readonly state: IterationState; + readonly prNumber?: number | null; + /** Full PR URL — makes the "PR #N" reference a clickable link when present. */ + readonly prUrl?: string | null; + /** Agent's answer (answered state). */ + readonly answerText?: string; + /** This iteration's cost (USD) — shown on terminal states. */ + readonly costUsd?: number | null; + /** Wall-clock seconds for this iteration — shown on terminal states. */ + readonly durationS?: number | null; + /** Cumulative cost across ALL iterations on this PR/issue (incl. this one). */ + readonly runningTotalUsd?: number | null; + /** + * Captured deploy-preview screenshot PNG (our CloudFront URL). Embedded as a + * clickable image thumbnail in the reply when present, NOT a standalone comment. + */ + readonly screenshotUrl?: string | null; + /** + * Live deploy URL (the Vercel/preview site). When present, the embedded + * screenshot links to it ({@link renderPreviewBlock}). MUST be markdown-escaped + * by the caller (payload-derived). + */ + readonly deployUrl?: string | null; + /** Sanitized failure reason (failed state). */ + readonly failureReason?: string; + /** + * Liveness heartbeat: seconds elapsed since the task started, shown on the + * ``working`` state so a long run isn't a silent black box ("🔄 Working — 8m + * elapsed…"). Only rendered when > a small floor (a just-started task shows + * the plain "Working…" line). Distinct from ``durationS`` (a TERMINAL total). + */ + readonly elapsedS?: number | null; + /** + * Short, sanitized latest-progress hint from the agent's most recent + * milestone (e.g. "running build verification"). Optional; appended to the + * working line when present. Caller MUST pre-sanitize (it's agent-derived). + */ + readonly progressNote?: string; +} + +/** + * The deploy-preview block folded into a maturing reply: the captured screenshot + * PNG embedded as an image, made CLICKABLE to the live deploy when the deploy URL + * is known (the user picked the clickable-thumbnail UX over a bare text link). + * - both urls → ``[![preview](screenshot.png)](deploy)`` (image links to deploy) + * - screenshot only → ``![preview](screenshot.png)`` (plain embed, no link target) + * - no screenshot → '' (nothing to show) + * ``screenshotUrl`` is our own CloudFront key (no parens) so it's safe as-is; + * ``deployUrl`` is payload-derived, so callers MUST pass it already + * markdown-escaped (see ``encodeMarkdownUrl``) to avoid a link-breakout. Pure. + */ +export function renderPreviewBlock( + screenshotUrl: string | null | undefined, + deployUrl?: string | null, +): string { + if (!screenshotUrl) return ''; + return deployUrl + ? `[![preview](${screenshotUrl})](${deployUrl})` + : `![preview](${screenshotUrl})`; +} + +/** Format a USD cost as "$X.XX", or "" when unknown. */ +function usd(n: number | null | undefined): string { + return typeof n === 'number' && Number.isFinite(n) ? `$${n.toFixed(2)}` : ''; +} + +/** Compact "Ns"/"Nm Ns" duration, or "" when unknown. */ +function dur(s: number | null | undefined): string { + if (typeof s !== 'number' || !Number.isFinite(s) || s < 0) return ''; + if (s < 60) return `${Math.round(s)}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s % 60); + return rem ? `${m}m ${rem}s` : `${m}m`; +} + +/** + * Render the maturing iteration reply for a given {@link IterationState}. Pure. + * The metadata line (cost · duration · running total) appears only on terminal + * states and only for the fields that are known. The screenshot is a link, not + * an embed, so the reply stays compact across many rounds. + */ +export function renderMaturingReply(input: MaturingReplyInput): string { + const meta = terminalMetaLine(input); + // Clickable image thumbnail (screenshot PNG → live deploy), on its own block. + const previewBlock = renderPreviewBlock(input.screenshotUrl, input.deployUrl); + + const prRef = prReference(input.prNumber, input.prUrl); + switch (input.state) { + case 'on_it': + return '👀 On it — reading the PR…'; + case 'working': { + // Liveness: base "Working" line + an optional "(Nm elapsed[ · note])" + // suffix so a long run shows it's alive, not stuck. The elapsed clause is + // omitted for a freshly-started task (< HEARTBEAT_ELAPSED_FLOOR_S) so the + // first ack reads clean. + const base = prRef ? `🔄 Working — updating ${prRef}…` : '🔄 Working…'; + const live = workingLivenessSuffix(input.elapsedS, input.progressNote); + return live ? `${base}\n${live}` : base; + } + case 'updated': { + const head = prRef ? `✅ Updated — ${prRef}.` : '✅ Updated.'; + // headline + metadata, then the embedded preview thumbnail on its own line. + const lines = [meta ? `${head}\n${meta}` : head]; + if (previewBlock) lines.push(previewBlock); + return lines.join('\n\n'); + } + case 'answered': { + const answer = (input.answerText ?? '').trim(); + const head = answer + ? `💬 ${truncate(answer, MAX_ANSWER_CHARS)}` + : '💬 No code change was needed — nothing to update on this PR.'; + return meta ? `${head}\n${meta}` : head; + } + case 'failed': { + const reason = (input.failureReason ?? '').trim(); + const head = reason ? `❌ ${truncate(reason, MAX_ANSWER_CHARS)}` : '❌ The iteration failed.'; + return meta ? `${head}\n${meta}` : head; + } + } +} + +/** A clickable "[PR #N](url)" when the url is known, else plain "PR #N", else "". */ +function prReference(prNumber: number | null | undefined, prUrl: string | null | undefined): string { + if (prNumber == null) return ''; + return prUrl ? `[PR #${prNumber}](${prUrl})` : `PR #${prNumber}`; +} + +/** "cost: $X · 2m 3s · total this PR: $Y" — only the known parts. */ +function terminalMetaLine(input: MaturingReplyInput): string { + const parts: string[] = []; + const c = usd(input.costUsd); + if (c) parts.push(c); + const d = dur(input.durationS); + if (d) parts.push(d); + const t = usd(input.runningTotalUsd); + if (t) parts.push(`total this PR: ${t}`); + return parts.length ? `_${parts.join(' · ')}_` : ''; +} + +export interface IterationReplyInput { + /** + * Did the iteration advance the PR branch (a real commit landed)? + * - ``true`` → a normal edit; render the "✅ Updated" success. + * - ``false`` → a no-op iteration (a question / nothing to change). + * - ``undefined`` → unknown (pre-fix task, non-PR workflow, or the agent + * couldn't read the baseline). Treated as ``true`` so + * behaviour is unchanged for anything that doesn't opt in. + */ + readonly codeChanged?: boolean; + /** The PR number, when resolvable (only used on the changed path). */ + readonly prNumber?: number | null; + /** The agent's final answer text, surfaced on the no-change path. */ + readonly answerText?: string; +} + +/** + * Render the reply for a SUCCESSFUL (completed + build-passing) iteration. + * (Failures are rendered by the existing ``renderFailureReply`` — this is only + * the success branch, which is where the false-"✅ Updated" lived.) + */ +export function renderIterationSuccessReply(input: IterationReplyInput): string { + const noChange = input.codeChanged === false; + + if (noChange) { + const answer = (input.answerText ?? '').trim(); + if (answer) { + return `💬 ${truncate(answer, MAX_ANSWER_CHARS)}`; + } + // No commit AND no captured answer — be honest that nothing changed rather + // than claim an update. (Rare: the agent settled without a result text.) + return '💬 No code change was needed — nothing to update on this PR.'; + } + + // Changed (or unknown → back-compat): the existing success ack. + return typeof input.prNumber === 'number' + ? `✅ Updated — PR #${input.prNumber}.` + : '✅ Updated.'; +} + +/** True when this is a no-change iteration (drives the 👀→💬 reaction choice). */ +export function isNoChangeIteration(codeChanged?: boolean): boolean { + return codeChanged === false; +} + +/** + * Matches a preview block folded onto a matured reply, in either shape + * {@link renderPreviewBlock} emits: a clickable thumbnail + * ``[![preview](png)](deploy)`` or a plain embed ``![preview](png)``. Captures + * the whole block so convergence re-attaches it verbatim (image + deploy link). + */ +const PREVIEW_BLOCK_RE = /\[?!\[preview\]\([^)\s]+\)(?:\]\([^)\s]+\))?/; + +/** + * Converge two independent writers of the same reply body: the deploy-preview + * block and the terminal-settle are written by two INDEPENDENT async paths (the + * screenshot webhook appends the ``![preview]`` block; the fanout/reconciler + * terminal-settle re-renders the whole reply body) with no ordering guarantee. + * Whichever runs last wins, so a terminal re-render would silently drop a preview + * the webhook already appended — observed live, with the preview wiped ~14 seconds + * after it landed. This makes the edit path CONVERGE rather than + * overwrite: if ``currentBody`` already carries a ``[preview]`` block and the + * freshly-rendered ``newBody`` does not, carry that exact block onto the new body + * on its own line. Pure; idempotent (a no-op when newBody already has its own + * preview or currentBody has none). + */ +export function preservePreviewSuffix(newBody: string, currentBody: string | null | undefined): string { + if (typeof currentBody !== 'string') return newBody; + if (newBody.includes('[preview]')) return newBody; // new render already carries one + const block = currentBody.match(PREVIEW_BLOCK_RE)?.[0]; + if (!block) return newBody; + return `${newBody}\n\n${block}`; +} + +/** + * Lead markers of a TERMINAL maturing-reply render — the states that mean "this + * iteration is finished": ✅ updated, 💬 answered, ❌ failed. Progress states + * (👀 on_it, 🔄 working) are deliberately absent. + * + * Kept beside {@link renderMaturingReply} so the two stay in step: if a terminal + * state's lead marker changes there, it must change here. + */ +const TERMINAL_REPLY_MARKERS: readonly string[] = ['✅', '💬', '❌']; + +/** + * Does this reply body already show a terminal outcome? + * + * Used to stop a LATE progress edit from overwriting a settled reply. Two writers + * edit the same comment — the terminal settle and the (stream-driven, so possibly + * delayed) progress milestone — and the body is the ground truth about which has + * landed, unlike a task-record marker, which is written slightly before the render + * it announces. Checking the body therefore closes the window between the two. + * + * Conservative by construction: only a known terminal lead marker counts, so an + * unrecognised body is treated as NOT settled and progress still renders (a + * missed progress edit is cosmetic; a reply frozen at "On it" is not). + */ +export function isTerminalMaturingReply(body: string | null | undefined): boolean { + if (typeof body !== 'string') return false; + const lead = body.trimStart(); + return TERMINAL_REPLY_MARKERS.some((marker) => lead.startsWith(marker)); +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/cdk/src/handlers/shared/linear-feedback.ts b/cdk/src/handlers/shared/linear-feedback.ts index 6597d9122..0baa00e04 100644 --- a/cdk/src/handlers/shared/linear-feedback.ts +++ b/cdk/src/handlers/shared/linear-feedback.ts @@ -17,15 +17,17 @@ * SOFTWARE. */ +import { isTerminalMaturingReply, preservePreviewSuffix } from './iteration-reply'; import { resolveLinearOauthToken } from './linear-oauth-resolver'; import { logger } from './logger'; +import { isBotAuthoredComment } from './orchestration-comment-trigger'; /** * Lambda-side helper for posting comments and reactions onto Linear issues * via direct GraphQL. Used by the webhook processor to give users feedback * on pre-container failures (guardrail block, concurrency cap, unmapped - * project, etc.) — paths where the agent never starts and the agent-side - * Linear MCP / `linear_reactions.py` cannot run. + * project, etc.) — paths where the agent never starts, so the agent-side + * `linear_reactions.py` (its only Linear I/O — there is no Linear MCP) can't run. * * All calls are best-effort. Errors are logged at WARN and swallowed — * Linear feedback is advisory and must never gate task-rejection logic. @@ -35,8 +37,19 @@ const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; const REQUEST_TIMEOUT_MS = 5000; -/** Reaction emoji short-code for the failure marker. Matches `EMOJI_FAILURE` in `agent/src/linear_reactions.py`. */ -const EMOJI_FAILURE = 'x'; +/** + * Reaction emoji short-codes. Match the agent-side child markers in + * ``agent/src/linear_reactions.py`` so the PARENT epic shows the same + * status signal as its sub-issues: 👀 at start, ✅/❌ at completion. + */ +export const EMOJI_STARTED = 'eyes'; +export const EMOJI_SUCCESS = 'white_check_mark'; +export const EMOJI_FAILURE = 'x'; +// A comment on a parent epic that we could NOT route to one specific sub-issue is +// a QUESTION, not work-in-progress — leaving the 👀 (EMOJI_STARTED) on it makes it +// look like the agent is still working. Swap to ❓ so the reaction matches the +// "I need you to clarify / pick a sub-issue" disambiguation reply. +export const EMOJI_NEEDS_INPUT = 'question'; const COMMENT_CREATE_MUTATION = ` mutation CreateComment($issueId: String!, $body: String!) { @@ -46,6 +59,107 @@ mutation CreateComment($issueId: String!, $body: String!) { } `.trim(); +/** Create a comment and return its id (for later edit-in-place). */ +const COMMENT_CREATE_RETURNING_ID_MUTATION = ` +mutation CreateCommentReturningId($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { id } + } +} +`.trim(); + +/** Edit an existing comment in place — this is what makes one comment able to + * serve as a live, self-updating status block instead of a stream of new ones. */ +const COMMENT_UPDATE_MUTATION = ` +mutation UpdateComment($id: String!, $body: String!) { + commentUpdate(id: $id, input: { body: $body }) { + success + } +} +`.trim(); + +/** Delete a comment — used to clear the transient "on it" ack once the revised + * plan it was acknowledging has been written into the thread. */ +const COMMENT_DELETE_MUTATION = ` +mutation DeleteComment($id: String!) { + commentDelete(id: $id) { success } +} +`.trim(); + +/** + * List an issue's TOP-LEVEL comments (id + body). Used to tidy the thread once a + * proposed decomposition plan is approved or rejected: we can't + * track every fire-and-forget note id (they're posted from ~15 sites), so we + * fetch the thread once and delete the bot's own ``🗂️``/``👋`` notes by prefix, + * keeping the frozen plan reference + the (differently-prefixed) live panel. + * ``first: 100`` comfortably covers a plan phase (a few notes + revise rounds); + * pagination is unnecessary for the transient-note volume this sweeps. + */ +const ISSUE_COMMENTS_QUERY = ` +query IssueComments($issueId: String!) { + issue(id: $issueId) { + comments(first: 100) { + nodes { id body } + } + } +} +`.trim(); + +/** + * Fetch comments with the author metadata needed to tell a human turn from a + * bot/integration one. All Linear I/O is deliberately platform-side and + * deterministic (ADR-016), so the agent cannot read the thread at runtime — it is + * fetched here and pre-hydrated into the task context instead. + * ``user`` is the human author (null when a comment + * was posted by an OAuth app / integration); ``botActor`` is present precisely + * for those app/integration comments — so @bgagent's own progress and ack + * comments carry a ``botActor`` and no ``user``. ``createdAt`` orders the thread. + * + * We fetch the first 100 (matching {@link ISSUE_COMMENTS_QUERY}) and sort + + * slice to the most recent human comments CLIENT-SIDE, so the result is + * independent of Linear's connection sort direction. 100 covers every realistic + * issue thread; the rare over-100 issue simply may miss the oldest turns, which + * is acceptable for advisory context. + */ +const RECENT_COMMENTS_QUERY = ` +query RecentComments($issueId: String!) { + issue(id: $issueId) { + comments(first: 100) { + nodes { + id + body + createdAt + user { displayName name } + botActor { id } + } + } + } +} +`.trim(); + +/** + * Post a THREADED REPLY beneath an existing comment, so the agent's response to a + * request lands under that request rather than as a new top-level comment. + * ``parentId`` is the comment being replied to; the reply notifies and reads + * as a conversation turn under it. Returns the new reply's id (for a possible + * later edit), distinct from a top-level comment. + * + * IMPORTANT (verified against the live API): Linear's ``commentCreate`` requires + * ``issueId`` to be present EVEN for a threaded reply — ``parentId`` alone + * fails ``commentCreate`` argument validation ("Exactly one of …issueId must + * be defined"). So the reply carries BOTH the parent comment id and its + * issue id. + */ +const COMMENT_REPLY_RETURNING_ID_MUTATION = ` +mutation ReplyToComment($issueId: String!, $parentId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, parentId: $parentId, body: $body }) { + success + comment { id } + } +} +`.trim(); + const REACTION_CREATE_MUTATION = ` mutation ReactIssue($issueId: String!, $emoji: String!) { reactionCreate(input: { issueId: $issueId, emoji: $emoji }) { @@ -54,6 +168,122 @@ mutation ReactIssue($issueId: String!, $emoji: String!) { } `.trim(); +/** + * React to a specific COMMENT (not the issue) — the instant "on it" ack on an + * ``@bgagent`` comment. (Verified against the live API: ``reactionCreate`` input + * accepts ``commentId``.) + */ +const REACTION_CREATE_ON_COMMENT_MUTATION = ` +mutation ReactComment($commentId: String!, $emoji: String!) { + reactionCreate(input: { commentId: $commentId, emoji: $emoji }) { + success + } +} +`.trim(); + +const REACTION_DELETE_MUTATION = ` +mutation UnreactIssue($id: String!) { + reactionDelete(id: $id) { success } +} +`.trim(); + +/** Read an issue's reactions (id + emoji) — to swap one bgagent marker for another. */ +const ISSUE_REACTIONS_QUERY = ` +query IssueReactions($issueId: String!) { + issue(id: $issueId) { reactions { id emoji } } +} +`.trim(); + +/** Read a COMMENT's reactions (id + emoji) — to swap the comment's bgagent marker. */ +const COMMENT_REACTIONS_QUERY = ` +query CommentReactions($commentId: String!) { + comment(id: $commentId) { reactions { id emoji } } +} +`.trim(); + +/** Read a COMMENT's current body — to append to it (e.g. the deploy-preview link + * that arrives after the reply has already settled). */ +const COMMENT_BODY_QUERY = ` +query CommentBody($commentId: String!) { + comment(id: $commentId) { body } +} +`.trim(); + +/** + * The bgagent status-marker emojis we manage on the PARENT epic. Mirrors + * ``_BGAGENT_EMOJIS`` in ``agent/src/linear_reactions.py``. Only these are + * ever deleted by {@link swapIssueReaction} — a human's reaction is never + * touched. + */ +const BGAGENT_EMOJIS: ReadonlySet = new Set([ + EMOJI_STARTED, EMOJI_SUCCESS, EMOJI_FAILURE, EMOJI_NEEDS_INPUT, +]); + +/** + * Fetch the workflow states for the TEAM that owns ``issueId``, so we can + * resolve a target state by its semantic ``type`` (Linear state IDs are + * per-team UUIDs, not knowable a priori). ``type`` values: + * ``backlog`` | ``unstarted`` (Todo) | ``started`` (In Progress / In Review) | + * ``completed`` (Done) | ``canceled``. + */ +const ISSUE_TEAM_STATES_QUERY = ` +query IssueTeamStates($issueId: String!) { + issue(id: $issueId) { + state { id type name position } + team { states { nodes { id type name position } } } + } +} +`.trim(); + +const ISSUE_SET_STATE_MUTATION = ` +mutation SetIssueState($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + success + } +} +`.trim(); + +interface TeamState { + readonly id: string; + readonly type: string; + readonly name: string; + readonly position: number; +} + +async function graphqlData( + accessToken: string, + query: string, + variables: Record, +): Promise | null> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear feedback GraphQL non-2xx', { status: resp.status }); + return null; + } + const body = (await resp.json()) as { data?: Record; errors?: unknown }; + if (body.errors) { + logger.warn('Linear feedback GraphQL errors', { errors: body.errors }); + return null; + } + return body.data ?? null; + } catch (err) { + logger.warn('Linear feedback request failed', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } finally { + clearTimeout(timer); + } +} + /** * Outcome of a Linear API call. ``retryable`` distinguishes transient * failures (network error, request timeout, HTTP 5xx/429) — where a @@ -78,9 +308,9 @@ async function graphqlRequest( const resp = await fetch(LINEAR_GRAPHQL_URL, { method: 'POST', headers: { - // OAuth tokens use Bearer; legacy PAK was the bare value. Phase - // 2.0b: all tokens stored in Secrets Manager are OAuth bearer - // tokens so we always Bearer-prefix. + // OAuth tokens use Bearer; a legacy personal API key was sent as the bare + // value. Every token stored in Secrets Manager is now an OAuth bearer + // token, so we always Bearer-prefix. 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, @@ -165,6 +395,183 @@ export async function postIssueComment( return graphqlRequest(token, COMMENT_CREATE_MUTATION, { issueId, body }); } +/** + * Upsert the orchestration's live status block — ONE comment on the parent epic + * that is rewritten as the run progresses, rather than a new comment per + * transition. If ``existingCommentId`` is given, EDIT that comment in place; otherwise + * CREATE a fresh comment and return its id so the caller can persist it and + * edit on the next transition. Returns the comment id on success (the + * existing id on update, the new id on create), or null on any failure. + * Best-effort — never throws; the status block is advisory. + */ +export async function upsertStatusComment( + ctx: LinearFeedbackContext, + issueId: string, + body: string, + existingCommentId?: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + + if (existingCommentId) { + // graphqlRequest now returns a LinearPostResult — read .ok (an object is + // always truthy, so a bare `ok ?` would wrongly report success). + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { id: existingCommentId, body })).ok; + return ok ? existingCommentId : null; + } + + const data = await graphqlData(token, COMMENT_CREATE_RETURNING_ID_MUTATION, { issueId, body }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * Delete a comment — used for the transient "🗂️ On it — updating…" ack once the + * revised plan it announced has been posted. Best-effort: returns true on + * success, false on any failure (a lingering ack is a cosmetic nit, never a + * breakage). Note Linear may already have fired a notification for the ack that + * deletion can't un-send — acceptable. + */ +export async function deleteComment( + ctx: LinearFeedbackContext, + commentId: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + return (await graphqlRequest(token, COMMENT_DELETE_MUTATION, { id: commentId })).ok; +} + +/** + * Bot-comment prefixes that mark a TRANSIENT note from the plan-proposal phase, + * where the platform decomposes a plain issue into a sub-issue graph for a human + * to approve (the "on it" ack, revise/escalate acks, planner-error, over-cap, + * already-decomposed, nudge, wrong-handle). These are the ``🗂️``/``👋`` comments + * the plan cleanup sweeps once the plan is approved or rejected. + * Deliberately does NOT include the live epic panel + * (``🔄``/``⚠️``/``✅``) or the agent's own progress (``🤖``) — those aren't ours + * to delete here, and the panel is the thing we're KEEPING. Mirrors the + * self-trigger guard's ``BOT_COMMENT_PREFIXES`` but scoped to the decompose notes. + */ +const DECOMPOSE_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; + +/** + * Sweep the bot's transient decomposition notes off an + * issue once the plan is approved/rejected, leaving just the frozen plan + * reference + the live epic panel. Fetches the thread once, then deletes every + * top-level comment that (a) starts with a decompose-note prefix and (b) is NOT + * ``keepCommentId`` (the frozen plan reference we just wrote). Best-effort and + * total: a failed list returns 0 (nothing swept — a lingering note is a + * cosmetic nit, never a breakage), and each delete is independent so one + * failure doesn't abort the rest. Returns the count deleted (for logging/tests). + * + * Prefix-scoping is the robustness win: interim notes are posted from ~15 + * fire-and-forget sites whose ids we don't track, and future note types are + * covered automatically — while the panel (different prefix) and human comments + * (no bot prefix) are provably untouched. + */ +export async function sweepDecompositionNotes( + ctx: LinearFeedbackContext, + issueId: string, + keepCommentId?: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return 0; + const data = await graphqlData(token, ISSUE_COMMENTS_QUERY, { issueId }); + const issue = data?.issue as { comments?: { nodes?: Array<{ id?: string; body?: string }> } } | undefined; + const nodes = issue?.comments?.nodes ?? []; + let deleted = 0; + for (const node of nodes) { + const id = node?.id; + const body = (node?.body ?? '').trimStart(); + if (!id || id === keepCommentId) continue; + if (!DECOMPOSE_NOTE_PREFIXES.some((p) => body.startsWith(p))) continue; + const ok = (await graphqlRequest(token, COMMENT_DELETE_MUTATION, { id })).ok; + if (ok) deleted += 1; + } + if (deleted > 0) { + logger.info('Swept transient decomposition notes at plan settle', { + issue_id: issueId, deleted, kept_reference: keepCommentId ?? null, + }); + } + return deleted; +} + +/** A rendered issue comment folded into the task context (mirrors the Jira + * ``RenderedComment`` shape so the two processors read alike). */ +export interface RenderedComment { + readonly author: string; + readonly createdAt: string; + readonly markdown: string; +} + +/** Default cap on recent human comments folded into the task context. */ +const DEFAULT_MAX_RECENT_COMMENTS = 20; + +interface RawLinearComment { + readonly id?: string; + readonly body?: string; + readonly createdAt?: string; + readonly user?: { readonly displayName?: string; readonly name?: string } | null; + readonly botActor?: { readonly id?: string } | null; +} + +/** + * Fetch the most recent HUMAN-authored comments on an issue, rendered to + * markdown oldest-first, for pre-hydrating the task context (the agent has no + * Linear tooling of its own to read the thread at runtime). Best-effort / fail-open: any + * failure (token, GraphQL error, malformed body) logs a WARN and returns ``[]`` + * so the task still proceeds — comments are advisory context, not a gate. + * + * "Human" excludes app/integration comments two ways (belt and suspenders): + * 1. ``botActor`` present, or ``user`` absent → posted by an OAuth app / + * integration (this is how @bgagent's own comments are marked); + * 2. the body starts with one of the bot's own rendered-comment markers + * ({@link isBotAuthoredComment}) — catches anything mis-attributed to a user. + */ +export async function fetchRecentComments( + ctx: LinearFeedbackContext, + issueId: string, + maxComments: number = DEFAULT_MAX_RECENT_COMMENTS, +): Promise { + const token = await resolveToken(ctx); + if (!token) return []; + const data = await graphqlData(token, RECENT_COMMENTS_QUERY, { issueId }); + const issue = data?.issue as { comments?: { nodes?: RawLinearComment[] } } | undefined; + const nodes = issue?.comments?.nodes ?? []; + + const human: RenderedComment[] = []; + for (const node of nodes) { + // Skip app/integration comments (bgagent + other bots): they carry a + // botActor and no human user. + if (node.botActor || !node.user) continue; + const body = (node.body ?? '').trim(); + if (!body) continue; + // Belt and suspenders: drop anything that reads as one of our own rendered + // comments even if it slipped through as a "user" comment. + if (isBotAuthoredComment(body)) continue; + human.push({ + author: node.user.displayName?.trim() || node.user.name?.trim() || 'Unknown', + createdAt: typeof node.createdAt === 'string' ? node.createdAt : '', + markdown: body, + }); + } + + // Order oldest-first so the thread reads naturally, then keep the most recent + // ``maxComments`` (sort is client-side — independent of Linear's connection + // sort direction). Comments without a timestamp sort last (treated as newest). + human.sort((a, b) => (a.createdAt || '￿').localeCompare(b.createdAt || '￿')); + const recent = human.length > maxComments ? human.slice(human.length - maxComments) : human; + + if (recent.length > 0) { + logger.info('Fetched recent human Linear comments for task context', { + linear_workspace_id: ctx.linearWorkspaceId, + issue_id: issueId, + count: recent.length, + }); + } + return recent; +} + /** * Add an emoji reaction onto a Linear issue. Defaults to ❌ — the failure marker * the agent uses on the success/failure side. Same result contract as @@ -180,6 +587,318 @@ export async function addIssueReaction( return graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji }); } +/** + * React to a specific Linear COMMENT. Used as the + * instant "on it" acknowledgement when a human ``@bgagent``s a comment — + * 👀 ({@link EMOJI_STARTED}) lands immediately, before the iteration task is + * even created, so the human knows the agent saw their request with zero + * comment clutter. Best-effort; returns true on success. + */ +export async function reactToComment( + ctx: LinearFeedbackContext, + commentId: string, + emoji: string = EMOJI_STARTED, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + // graphqlRequest returns a LinearPostResult (an object, so always truthy); + // this best-effort helper just needs the success bool off it. + return (await graphqlRequest(token, REACTION_CREATE_ON_COMMENT_MUTATION, { commentId, emoji })).ok; +} + +/** + * Post a THREADED REPLY beneath a Linear comment. Used + * when the agent's work on an ``@bgagent`` comment lands ("✅ Updated — PR #N") + * or fails ("❌ …"). Unlike an edit, a reply NOTIFIES and reads as a + * conversation turn under the original request, keeping the thread contextual. + * Returns the new reply's comment id (for a possible later edit) or null on any + * failure. Best-effort — never throws. + * + * ``issueId`` is the issue the parent comment lives on — Linear requires it on + * ``commentCreate`` even for a reply (see {@link COMMENT_REPLY_RETURNING_ID_MUTATION}). + */ +export async function replyToComment( + ctx: LinearFeedbackContext, + issueId: string, + parentCommentId: string, + body: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + const data = await graphqlData(token, COMMENT_REPLY_RETURNING_ID_MUTATION, { + issueId, parentId: parentCommentId, body, + }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * The MATURING THREADED REPLY for a comment-iteration. + * If ``existingReplyId`` is given, EDIT that reply in place; otherwise CREATE a + * new reply threaded under ``parentCommentId`` and return its id. Mirrors + * {@link upsertStatusComment} but as a THREADED reply (carries ``parentId``) so + * one iteration shows a single reply that matures 👀→🔄→✅/💬 instead of N + * top-level comments. Returns the reply id (existing on edit, new on create), + * or null on any failure. Best-effort — never throws. + * + * Linear requires ``issueId`` even for a threaded reply (parentId alone fails + * commentCreate validation — verified against the live API), so the create + * carries both. + */ +export async function upsertThreadedReply( + ctx: LinearFeedbackContext, + issueId: string, + parentCommentId: string, + body: string, + existingReplyId?: string, + options?: { preservePreview?: boolean; skipIfSettled?: boolean; repairIfOverwritten?: boolean }, +): Promise { + const token = await resolveToken(ctx); + if (!token) return null; + + if (existingReplyId) { + let finalBody = body; + // Both options below need the CURRENT body, so read it once. + let current: string | undefined; + if (options?.preservePreview || options?.skipIfSettled) { + const data = await graphqlData(token, COMMENT_BODY_QUERY, { commentId: existingReplyId }); + current = (data?.comment as { body?: string } | undefined)?.body; + } + // A PROGRESS edit must never overwrite an outcome. The terminal settle and the + // (stream-driven, so possibly delayed) progress milestone edit the same + // comment, and the body is the ground truth about which has landed — a + // task-record marker is stamped slightly BEFORE the render it announces, so + // checking the body is what closes that window. Yield rather than clobber: + // losing a progress edit is cosmetic, losing the outcome is not. + // + // This check is a read followed by a separate write, so it NARROWS the window + // rather than closing it: a settle landing in between is still overwritten. + // Linear's comment update takes only an id and a body — there is no version + // or etag to make the write conditional on what was read (verified against + // the live schema) — so no amount of care here makes it atomic. The terminal + // writer therefore verifies its own body afterwards; see + // ``repairIfOverwritten`` below, which is what actually recovers the outcome. + if (options?.skipIfSettled && isTerminalMaturingReply(current)) { + logger.info('Skipping a progress edit — the reply already shows a terminal outcome', { + comment_id: existingReplyId, + }); + return existingReplyId; + } + // The deploy-preview link is appended by a SEPARATE async path (the + // screenshot webhook), so a terminal-settle re-render here can clobber a + // preview that already landed — this was observed live, the preview appended + // and then wiped by the terminal edit ~14 seconds later. When asked, carry an + // existing `[preview]` segment onto the new body so the two writers converge + // regardless of which one runs last. + if (options?.preservePreview) { + finalBody = preservePreviewSuffix(body, current); + } + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { id: existingReplyId, body: finalBody })).ok; + if (!ok) return null; + if (options?.repairIfOverwritten) { + await repairOverwrittenOutcome(token, existingReplyId, finalBody); + } + return existingReplyId; + } + + const data = await graphqlData(token, COMMENT_REPLY_RETURNING_ID_MUTATION, { + issueId, parentId: parentCommentId, body, + }); + const created = data?.commentCreate as { success?: boolean; comment?: { id?: string } } | undefined; + return created?.success && created.comment?.id ? created.comment.id : null; +} + +/** + * After writing an OUTCOME, check it is still there, and put it back if a + * concurrently-delivered progress edit landed on top of it. + * + * Why this exists rather than a conditional write: the progress writers avoid + * clobbering by reading the body and refusing when it already shows an outcome, + * but read-then-write is not atomic and Linear's comment update accepts only an + * id and a body — there is no version or etag to condition on (verified against + * the live schema), so the interleaving cannot be prevented at the surface. It + * can, however, be detected and undone by the writer that knows which body + * matters, and only the outcome is worth that extra round trip. + * + * Deliberately narrow. It re-asserts only when the body has regressed to a + * NON-terminal render, so it never fights a later legitimate outcome (a failure + * reply superseding a success, or a second iteration's settle) and never touches + * an unrecognised body a human may have edited. One repair attempt, no loop: if + * it loses again the next progress edit will itself observe the terminal body and + * yield. Best-effort throughout — a failed repair is logged, never thrown. + */ +async function repairOverwrittenOutcome( + accessToken: string, + commentId: string, + outcomeBody: string, +): Promise { + if (!isTerminalMaturingReply(outcomeBody)) return; // only outcomes are worth defending + const data = await graphqlData(accessToken, COMMENT_BODY_QUERY, { commentId }); + const current = (data?.comment as { body?: string } | undefined)?.body; + // Unreadable → leave it alone: acting on an unknown body could overwrite + // something newer, and the reply most likely still holds the outcome. + if (typeof current !== 'string') return; + if (current === outcomeBody || isTerminalMaturingReply(current)) return; + + logger.warn('A progress edit overwrote a settled reply — restoring the outcome', { + event: 'iteration_reply.outcome_restored', + comment_id: commentId, + }); + // Re-assert the outcome, carrying over any preview the screenshot webhook may + // have appended in the meantime so the repair doesn't drop it. + const restored = preservePreviewSuffix(outcomeBody, current); + const ok = (await graphqlRequest(accessToken, COMMENT_UPDATE_MUTATION, { id: commentId, body: restored })).ok; + if (!ok) { + logger.warn('Could not restore the overwritten outcome — the reply may read as in-progress', { + event: 'iteration_reply.outcome_restore_failed', + comment_id: commentId, + }); + } +} + +/** + * Append a one-line suffix to an existing comment, idempotently. + * Reads the comment's current body, and if it does NOT already contain + * ``marker``, appends ``\n``+``line`` and updates. Used by the screenshot webhook + * to add the ``· [preview](url)`` link to the iteration's settle reply once the + * (async) capture finishes — the reply has usually already rendered ✅ + cost by + * then, so the link arrives a few seconds later as an in-place edit rather than a + * new comment. ``marker`` is a stable substring (e.g. ``[preview]``) so a webhook + * redelivery doesn't append twice. Best-effort; returns true only if appended. + */ +export async function appendOnceToComment( + ctx: LinearFeedbackContext, + commentId: string, + line: string, + marker: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + const data = await graphqlData(token, COMMENT_BODY_QUERY, { commentId }); + const current = (data?.comment as { body?: string } | undefined)?.body; + if (typeof current !== 'string') return false; + if (current.includes(marker)) return false; // already appended (idempotent) + const ok = (await graphqlRequest(token, COMMENT_UPDATE_MUTATION, { + id: commentId, body: `${current}\n${line}`, + })).ok; + return ok; +} + +/** + * Delete one of our own status markers, retrying ONCE on a transient failure. + * + * A marker we fail to remove is not cosmetic: it leaves two of our reactions on + * the same item ("saw it" beside "done"), no caller inspects the swap's result, + * and nothing else ever revisits it — so the contradiction is permanent. That is + * exactly what happened live: a 5s request timeout aborted one delete and the + * pair stayed. One retry costs a single call and covers the blip; anything + * terminal (auth, a marker another writer already removed) is not retried, + * because re-sending cannot change the answer. + * + * Returns true when the marker is gone. + */ +async function deleteOwnMarker(accessToken: string, reactionId: string): Promise { + const first = await graphqlRequest(accessToken, REACTION_DELETE_MUTATION, { id: reactionId }); + if (first.ok) return true; + if (!first.retryable) return false; + logger.info('Retrying a status-marker removal after a transient failure', { reaction_id: reactionId }); + return (await graphqlRequest(accessToken, REACTION_DELETE_MUTATION, { id: reactionId })).ok; +} + +/** + * Swap the PARENT epic's bgagent status marker so only ONE is shown at a + * time (👀 → ✅/❌), mirroring the children's reaction behaviour. The + * children capture the reaction id in-process and delete it; the parent's + * markers are added across SEPARATE lambda invocations (👀 at seed, ✅/❌ at + * completion), so we instead query the issue's reactions, delete every + * bgagent marker EXCEPT the target, then add the target if absent. Only + * bgagent emojis (👀/✅/❌) are ever removed — a human's reaction is left + * untouched. Best-effort; returns true if the target marker is present + * afterwards. + */ +export async function swapIssueReaction( + ctx: LinearFeedbackContext, + issueId: string, + emoji: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_REACTIONS_QUERY, { issueId }); + const reactions = ((data?.issue as { reactions?: Array<{ id: string; emoji: string }> } | undefined)?.reactions) ?? []; + + // Delete our stale markers (any bgagent emoji that isn't the target). + let targetPresent = false; + let staleLeft = 0; + for (const r of reactions) { + if (r.emoji === emoji) { targetPresent = true; continue; } + if (BGAGENT_EMOJIS.has(r.emoji)) { + if (!(await deleteOwnMarker(token, r.id))) staleLeft += 1; + } + } + + // A marker we could not remove means the issue still shows two of our own + // reactions at once, which is the contradictory state this swap exists to + // prevent — so don't report the promised single marker. + if (staleLeft > 0) { + logger.warn('Could not remove a stale status marker from the issue', { + event: 'linear_feedback.issue_reaction_swap_incomplete', + issue_id: issueId, + stale_count: staleLeft, + }); + } + if (targetPresent) return staleLeft === 0; // already present; the deletes decide + const added = (await graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji })).ok; + return added && staleLeft === 0; +} + +/** + * Swap the bgagent status marker on a COMMENT (👀 → ✅/❌), so the trigger + * comment shows ONE marker reflecting the outcome — mirrors + * {@link swapIssueReaction} but on a comment. The 👀 lands at + * receipt ({@link reactToComment}); when the iteration settles we swap it for + * ✅ (success) / ❌ (failure) so the comment itself reads done at a glance, not + * just the threaded reply. Queries the comment's reactions, deletes every + * bgagent marker except the target, adds the target if absent. Only bgagent + * emojis (👀/✅/❌) are removed — a human's reaction is never touched. + * Idempotent (a reconciler redelivery re-converges to the same single marker). + * Best-effort; returns true only when the target is present AND no stale marker + * of ours was left behind — two markers at once is the state this prevents. + */ +export async function swapCommentReaction( + ctx: LinearFeedbackContext, + commentId: string, + emoji: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, COMMENT_REACTIONS_QUERY, { commentId }); + const reactions = ((data?.comment as { reactions?: Array<{ id: string; emoji: string }> } | undefined)?.reactions) ?? []; + + let targetPresent = false; + let staleLeft = 0; + for (const r of reactions) { + if (r.emoji === emoji) { targetPresent = true; continue; } + if (BGAGENT_EMOJIS.has(r.emoji)) { + if (!(await deleteOwnMarker(token, r.id))) staleLeft += 1; + } + } + + // Same all-or-nothing result as the issue swap: a leftover marker leaves the + // comment showing "saw it" beside "done", so it is not a success. + if (staleLeft > 0) { + logger.warn('Could not remove a stale status marker from the comment', { + event: 'linear_feedback.comment_reaction_swap_incomplete', + comment_id: commentId, + stale_count: staleLeft, + }); + } + if (targetPresent) return staleLeft === 0; + const added = (await graphqlRequest(token, REACTION_CREATE_ON_COMMENT_MUTATION, { commentId, emoji })).ok; + return added && staleLeft === 0; +} + /** * Convenience: post a feedback comment **and** drop a ❌ reaction in one call. * Both calls run in parallel; both are best-effort. Returns void — callers @@ -195,3 +914,179 @@ export async function reportIssueFailure( addIssueReaction(ctx, issueId, EMOJI_FAILURE), ]); } + +/** + * Pick the target workflow state by semantic preference. ``preferredNames`` + * (case-insensitive) is tried first so e.g. "In Review" wins over "In + * Progress" when both share Linear ``type: started``; falls back to the + * lowest-``position`` state of ``type``. Returns null if the team has no + * state of that type. + */ +function pickState( + states: readonly TeamState[], + type: string, + preferredNames: readonly string[], +): TeamState | null { + const ofType = states.filter((s) => s.type === type); + if (ofType.length === 0) return null; + for (const name of preferredNames) { + const hit = ofType.find((s) => s.name.toLowerCase() === name.toLowerCase()); + if (hit) return hit; + } + return [...ofType].sort((a, b) => a.position - b.position)[0]; +} + +/** + * Transition a Linear issue to a workflow state chosen by semantic ``type`` + * (+ optional name preference). Used by the orchestration reconciler to move the + * PARENT epic through its lifecycle — ``In Progress`` when the orchestration + * seeds, ``In Review`` when all children succeed — since the parent spawns no + * task and Linear's GitHub automation (which moves the children on PR-open) + * never touches it. + * + * Best-effort, like the rest of this module: resolves the team's states, + * picks the target, and issues ``issueUpdate``. Returns true only on a + * confirmed transition. Skips (returns false) if the issue is already in the + * target state or moving backward (we never demote, e.g. a human already + * pushed the epic to Done). Never throws. + */ +export async function transitionIssueState( + ctx: LinearFeedbackContext, + issueId: string, + targetType: 'started' | 'completed', + preferredNames: readonly string[] = [], + /** + * Allow a WITHIN-TYPE regression (e.g. In Review → In Progress, both + * ``started``). By default the backward-move guard blocks it — correct for most + * callers, but it silently no-op'd the orchestration rollup's deliberate + * re-open (a settled epic that gains a new or re-run child must go back from In + * Review to In Progress, and since both are ``started`` type the + * position-tiebreak blocked it). Cross-TYPE demotion (completed → started) is + * still ALWAYS blocked — this only relaxes the same-type position tiebreak. + */ + allowSameTypeRegression = false, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_TEAM_STATES_QUERY, { issueId }); + const issue = data?.issue as + | { state?: TeamState; team?: { states?: { nodes?: TeamState[] } } } + | undefined; + const states = issue?.team?.states?.nodes ?? []; + if (states.length === 0) { + logger.warn('Linear state transition: no team states resolved', { issue_id: issueId }); + return false; + } + + const target = pickState(states, targetType, preferredNames); + if (!target) { + logger.warn('Linear state transition: no state of target type', { issue_id: issueId, target_type: targetType }); + return false; + } + + const current = issue?.state; + if (current?.id === target.id) { + // Already there — idempotent no-op (e.g. reconciler re-fires). + return false; + } + // Never move backward. Order by state TYPE first (the lifecycle: + // backlog → unstarted → started → completed/canceled), then by position + // within the same type. Raw position is NOT lifecycle order — e.g. Done + // (completed, position 3) sorts numerically before In Review (started, + // position 1002), so a position-only guard would wrongly demote a + // human-completed epic back to In Review. We never demote across types + // (a human/automation advanced it) nor backward within a type. + if (current) { + const TYPE_RANK: Record = { + backlog: 0, unstarted: 1, started: 2, completed: 3, canceled: 3, triage: 0, + }; + const curRank = TYPE_RANK[current.type] ?? 0; + const tgtRank = TYPE_RANK[target.type] ?? 0; + const crossTypeDemotion = curRank > tgtRank; + const sameTypeRegression = curRank === tgtRank && current.position >= target.position; + // Cross-type demotion (e.g. completed → started) is NEVER allowed — a human + // or automation advanced it. A same-type regression (In Review → In Progress) + // is allowed ONLY when the caller opts in, which is how the orchestration + // rollup deliberately re-opens a settled epic. + const backward = crossTypeDemotion || (sameTypeRegression && !allowSameTypeRegression); + if (backward) { + logger.info('Linear state transition: skipping backward move', { + issue_id: issueId, + current_state: current.name, + target_state: target.name, + cross_type: crossTypeDemotion, + }); + return false; + } + } + + const ok = (await graphqlRequest(token, ISSUE_SET_STATE_MUTATION, { issueId, stateId: target.id })).ok; + if (ok) { + logger.info('Linear issue state transitioned', { + issue_id: issueId, + from: current?.name, + to: target.name, + }); + } + return ok; +} + +/** + * Move an issue BACKWARD to a not-started state + * (``unstarted`` "Todo", else ``backlog``), used when a ``:decompose`` planning + * run finishes and the issue is now awaiting the reviewer's approve. The webhook + * moved it to In Progress at dispatch (so the board showed the ~1-2 min planning + * WAS happening — {@link transitionIssueState}); once the plan is posted and + * nothing is running, "In Progress" would lie ("looks like work started while + * it's just a pending plan"). So we revert it. + * + * This is the ONE sanctioned backward move, and it's tightly guarded to avoid + * clobbering a human: it ONLY fires when the issue is CURRENTLY in a ``started`` + * state (i.e. still the In Progress we set) — if a human already pushed it to + * Done/Canceled, or pulled it back to Backlog themselves, we leave it. Prefers a + * "Todo" then "Backlog" target name; falls back to the lowest-position + * unstarted/backlog state. Best-effort, never throws. + */ +export async function revertIssueToNotStarted( + ctx: LinearFeedbackContext, + issueId: string, +): Promise { + const token = await resolveToken(ctx); + if (!token) return false; + + const data = await graphqlData(token, ISSUE_TEAM_STATES_QUERY, { issueId }); + const issue = data?.issue as + | { state?: TeamState; team?: { states?: { nodes?: TeamState[] } } } + | undefined; + const states = issue?.team?.states?.nodes ?? []; + const current = issue?.state; + if (states.length === 0 || !current) return false; + + // Only revert OUR "In Progress" — never demote a human-advanced (completed/ + // canceled) or a human-pulled-back (already backlog/unstarted) issue. + if (current.type !== 'started') { + logger.info('Revert-to-not-started: issue not in a started state — leaving it', { + issue_id: issueId, current_state: current.name, current_type: current.type, + }); + return false; + } + + // Prefer an unstarted "Todo" (the natural "not started, waiting" state); fall + // back to backlog. Within a type, prefer the named state, else lowest position. + const target = pickState(states, 'unstarted', ['Todo', 'To Do']) + ?? pickState(states, 'backlog', ['Backlog', 'Triage']); + if (!target) { + logger.info('Revert-to-not-started: no unstarted/backlog state on the team — leaving it', { issue_id: issueId }); + return false; + } + if (target.id === current.id) return false; + + const ok = (await graphqlRequest(token, ISSUE_SET_STATE_MUTATION, { issueId, stateId: target.id })).ok; + if (ok) { + logger.info('Linear issue reverted to not-started (awaiting approval)', { + issue_id: issueId, from: current.name, to: target.name, + }); + } + return ok; +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts index b23738875..9073f5291 100644 --- a/cdk/src/handlers/shared/linear-issue-lookup.ts +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -25,7 +25,7 @@ import { logger } from './logger'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); /** - * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * Linear issue identifier shape, e.g. `ENG-42`. Linear identifiers are * `-` where the key is uppercase letters and digits is * a positive integer. We bound the team key length [1,10] and number * length [1,8] to avoid pathological inputs. @@ -33,11 +33,11 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; /** - * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * Pull the first Linear issue identifier (e.g. `ENG-42`) found in * the given text. PR titles and bodies typically include this either * because the agent's task_description carries the identifier, or * because Linear's own GitHub integration auto-injects an - * `ABCA-42 ` reference. + * `ENG-42 ` reference. * * Returns the first match in document order. If multiple distinct * identifiers are present we still return the first — multi-issue PRs @@ -52,6 +52,35 @@ export function extractLinearIdentifier(text: string | null | undefined): string return match ? `${match[1]}-${match[2]}` : null; } +/** + * Pull the Linear identifier out of an ABCA-generated git branch name. + * + * This is the *authoritative* identifier source for the screenshot + * router, and it must be tried before PR title/body. ABCA derives every + * task branch as `bgagent/{taskId}/{slug}` where the slug is + * `slugify("ENG-151: ")` — so the identifier is ALWAYS the + * leading slug segment (see `generateBranchName` / `slugify` in + * `gateway.ts`, and the `${identifier}: ${title}` description built in + * `linear-webhook-processor.ts` / `orchestration-release.ts`). + * + * Why branch-first matters: in a stacked sub-issue + * orchestration, an agent's PR *body* commonly narrates the predecessor + * issue ("cherry-picked from ENG-151 … Closes ENG-152") before the + * issue the PR actually closes. `extractLinearIdentifier` returns the + * first match in document order, so body-first routing misattributes the + * screenshot to the predecessor. The branch name has no such ambiguity — + * it encodes exactly one issue, the PR's own. + * + * The slug is lowercased by `slugify`, so we upper-case before matching + * (the identifier regex anchors on `[A-Z]`). The ULID `taskId` segment + * contains no `-`, so it can never produce a false `<KEY>-<n>` match + * ahead of the real identifier. + */ +export function extractLinearIdentifierFromBranch(branchName: string | null | undefined): string | null { + if (!branchName) return null; + return extractLinearIdentifier(branchName.toUpperCase()); +} + /** * Resolved Linear issue location, paired with the workspace that owns * it. The screenshot processor uses these to construct a @@ -73,19 +102,19 @@ query IssueByIdentifier($identifier: String!) { `.trim(); /** - * Look up a Linear issue by identifier (e.g. `ABCA-42`). + * Look up a Linear issue by identifier (e.g. `ENG-42`). * * Routing strategy: * 1. Scan active workspaces (one round-trip — typical stacks have 1–2). - * 2. If any row's `team_keys` contains the identifier's team key (`ABCA`), + * 2. If any row's `team_keys` contains the identifier's team key (`ENG`), * query that workspace directly and return on hit. * 3. Otherwise fall back to iterating every active workspace until one - * returns a match. This handles legacy rows missing `team_keys` (the - * column was added in #96 and back-fills only on next `setup` / - * `add-workspace` re-run) and the rare case where a team was added in - * Linear after the workspace was registered. + * returns a match. This handles rows written before `team_keys` existed + * (it back-fills only on the next `setup` / `add-workspace` re-run) and + * the rare case where a team was added in Linear after the workspace was + * registered. * - * @param identifier `ABCA-42`-style Linear issue identifier + * @param identifier `ENG-42`-style Linear issue identifier * @param registryTableName name of LinearWorkspaceRegistryTable * @returns issue location, or null if no workspace contains the issue */ @@ -122,7 +151,7 @@ export async function findLinearIssueByIdentifier( return null; } - // Identifier prefix is the part before the first dash (`ABCA-42` → `ABCA`). + // Identifier prefix is the part before the first dash (`ENG-42` → `ENG`). // Compare uppercase since Linear team keys are upper-case but inbound text // (PR titles, branch names) is mixed-case. const teamKey = identifier.split('-', 1)[0]?.toUpperCase(); diff --git a/cdk/src/handlers/shared/linear-subissue-fetch.ts b/cdk/src/handlers/shared/linear-subissue-fetch.ts new file mode 100644 index 000000000..24892d427 --- /dev/null +++ b/cdk/src/handlers/shared/linear-subissue-fetch.ts @@ -0,0 +1,331 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Fetch a Linear parent issue's sub-issue dependency graph, as authored + * by a human in the tracker. Reads ``children`` (sub-issues) and, per + * child, its ``inverseRelations`` of type ``blocks`` (what blocks it) to + * build ``depends_on`` edges, then hands the result to + * ``orchestration-dag.ts::validateDag``. + * + * Direct GraphQL against Linear, Bearer-authenticated with the + * per-workspace OAuth token resolved by ``resolveLinearOauthToken``. + * Mirrors the request shape proven in ``linear-feedback.ts``. + * + * Unlike the best-effort feedback path, discovery is load-bearing: a + * fetch failure must be distinguishable from "this issue genuinely has + * no sub-issues" so the caller (the webhook processor) can decide + * whether to fall back to a single task or surface an error. Hence the + * discriminated ``FetchSubIssueGraphResult`` rather than a bare array. + */ + +import { logger } from './logger'; +import type { DagNode } from './orchestration-dag'; + +const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'; + +const REQUEST_TIMEOUT_MS = 8000; + +/** Linear `IssueRelation.type` value meaning "source blocks target". */ +const RELATION_TYPE_BLOCKS = 'blocks'; + +/** + * Page size for the children / relations connections. + * + * This query fetches a SINGLE page (no cursor loop), and nothing upstream caps + * the size of a human-authored sub-issue graph. So an over-size epic used to be + * SILENTLY truncated to the first 100 children AND the dropped children's + * dependency edges were filtered out, leaving the survivors to release out of + * order. Rather than truncate, we detect ``hasNextPage`` on either connection + * and return an explicit ``error`` (fail loud) so the operator splits the epic + * instead of getting a wrong-order run. 100 is the ceiling for a single epic; a + * genuinely larger workload should be several epics. + */ +const CONNECTION_PAGE_SIZE = 100; + +/** + * GraphQL: fetch a parent issue's children and each child's blockers. + * + * For child C, ``inverseRelations`` of type ``blocks`` are relations + * whose *source* issue blocks C — i.e. C's predecessors. We take the + * related issue id from each as a ``depends_on`` edge. ``pageInfo.hasNextPage`` + * on both connections lets us detect (and reject) a truncated over-size graph. + */ +const SUB_ISSUE_GRAPH_QUERY = ` +query SubIssueGraph($issueId: String!, $first: Int!) { + issue(id: $issueId) { + id + identifier + children(first: $first) { + pageInfo { hasNextPage } + nodes { + id + identifier + title + description + inverseRelations(first: $first) { + pageInfo { hasNextPage } + nodes { + type + issue { id } + } + } + } + } + } +} +`.trim(); + +/** One sub-issue plus the metadata the orchestration row needs. */ +export interface SubIssueNode extends DagNode { + /** Linear sub-issue UUID (same as ``id``). */ + readonly id: string; + /** Human-readable identifier (e.g. ``ENG-42``) for comments/logs. */ + readonly identifier?: string; + /** Sub-issue title for the task description. */ + readonly title?: string; + /** + * Sub-issue scope/description. The decompose planner writes a rich + * per-piece scope — often naming a concrete deliverable (a file, a route) — + * and that scope is what the reviewer approves. It must reach the coding + * agent so it builds what the plan promised (e.g. the exact filename), not a + * title-only guess. Populated from the plan when a planner seeded the graph; + * absent when an existing sub-issue graph is fetched by title only. + */ + readonly description?: string; + /** Sub-issue ids that block this one (intra-epic predecessors). */ + readonly depends_on: readonly string[]; +} + +export type FetchSubIssueGraphResult = + | { readonly kind: 'ok'; readonly parentIssueId: string; readonly children: readonly SubIssueNode[] } + | { readonly kind: 'no_children'; readonly parentIssueId: string } + | { readonly kind: 'error'; readonly message: string }; + +interface RawRelationNode { + readonly type?: string; + readonly issue?: { readonly id?: string } | null; +} + +interface RawPageInfo { + readonly hasNextPage?: boolean; +} + +interface RawChildNode { + readonly id?: string; + readonly identifier?: string; + readonly title?: string; + readonly description?: string; + readonly inverseRelations?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawRelationNode[] } | null; +} + +interface RawSubIssueGraph { + readonly data?: { + readonly issue?: { + readonly id?: string; + readonly children?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawChildNode[] } | null; + } | null; + }; + readonly errors?: unknown; +} + +export interface FetchSubIssueGraphOptions { + /** Override fetch for tests. */ + readonly fetchImpl?: typeof fetch; +} + +/** + * Fetch + shape a parent issue's sub-issue dependency graph. + * + * Returns: + * - ``ok`` — at least one child; ``children`` carry ``depends_on`` + * edges restricted to siblings within this child set (edges pointing + * outside the set are dropped here and surface as a dangling-edge + * rejection only if the caller chooses to keep them; we keep them so + * ``validateDag`` can flag a genuinely malformed graph). + * - ``no_children`` — the issue exists but has no sub-issues (caller + * falls back to a single task). + * - ``error`` — network / auth / GraphQL failure (caller surfaces + * a retryable error; does NOT silently treat as "no children"). + * + * Never throws. + */ +export async function fetchSubIssueGraph( + accessToken: string, + parentIssueId: string, + options: FetchSubIssueGraphOptions = {}, +): Promise<FetchSubIssueGraphResult> { + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let raw: RawSubIssueGraph; + try { + const resp = await fetchImpl(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: SUB_ISSUE_GRAPH_QUERY, + variables: { issueId: parentIssueId, first: CONNECTION_PAGE_SIZE }, + }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear sub-issue fetch non-2xx', { status: resp.status, parent_issue_id: parentIssueId }); + return { kind: 'error', message: `Linear API returned status ${resp.status}.` }; + } + raw = (await resp.json()) as RawSubIssueGraph; + } catch (err) { + logger.warn('Linear sub-issue fetch failed', { + parent_issue_id: parentIssueId, + error: err instanceof Error ? err.message : String(err), + }); + return { kind: 'error', message: 'Could not reach the Linear API to read sub-issues.' }; + } finally { + clearTimeout(timer); + } + + if (raw.errors) { + logger.warn('Linear sub-issue fetch GraphQL errors', { parent_issue_id: parentIssueId, errors: raw.errors }); + return { kind: 'error', message: 'Linear API reported an error reading sub-issues.' }; + } + + const issue = raw.data?.issue; + if (!issue || !issue.id) { + return { kind: 'error', message: 'Linear issue not found or not accessible with the workspace token.' }; + } + + const childNodes = issue.children?.nodes ?? []; + if (childNodes.length === 0) { + return { kind: 'no_children', parentIssueId: issue.id }; + } + + // Fail LOUD on truncation instead of silently dropping children (and + // their dependency edges). If Linear reports more children than + // one page holds, or any child has more blockers than one page holds, we can't + // build a correct DAG from a single fetch — reject so the operator splits the + // epic rather than getting a silently-wrong-order run. + if (issue.children?.pageInfo?.hasNextPage) { + logger.warn('Linear sub-issue fetch truncated — parent has more children than one page', { + parent_issue_id: issue.id, page_size: CONNECTION_PAGE_SIZE, + }); + return { + kind: 'error', + message: `This epic has more than ${CONNECTION_PAGE_SIZE} sub-issues — too many for a single ` + + `orchestration. Split it into multiple epics of at most ${CONNECTION_PAGE_SIZE} sub-issues each.`, + }; + } + const truncatedBlockersChild = childNodes.find((c) => c.inverseRelations?.pageInfo?.hasNextPage); + if (truncatedBlockersChild) { + logger.warn('Linear sub-issue fetch truncated — a child has more blockers than one page', { + parent_issue_id: issue.id, child_id: truncatedBlockersChild.id, page_size: CONNECTION_PAGE_SIZE, + }); + return { + kind: 'error', + message: `A sub-issue has more than ${CONNECTION_PAGE_SIZE} blocking relations — too many to order ` + + `reliably. Reduce the cross-dependencies on sub-issue ${truncatedBlockersChild.identifier ?? truncatedBlockersChild.id}.`, + }; + } + + // Restrict depends_on edges to ids that are themselves children of + // this parent — a "blocks" relation pointing at an issue outside the + // epic is not an intra-epic ordering constraint. (validateDag also + // guards dangling edges, but filtering here keeps the persisted graph + // clean and the dangling check meaningful for genuinely malformed + // intra-epic references only.) + const childIds = new Set( + childNodes.map((c) => c.id).filter((id): id is string => typeof id === 'string'), + ); + + const children: SubIssueNode[] = []; + for (const c of childNodes) { + if (!c.id) continue; + const blockers = (c.inverseRelations?.nodes ?? []) + .filter((r) => r.type === RELATION_TYPE_BLOCKS) + .map((r) => r.issue?.id) + .filter((id): id is string => typeof id === 'string' && id !== c.id && childIds.has(id)); + children.push({ + id: c.id, + ...(c.identifier !== undefined && { identifier: c.identifier }), + ...(c.title !== undefined && { title: c.title }), + // A human-authored sub-issue often carries its OWN scope in the + // description (a checklist, an acceptance criterion). This used to fetch + // title-only, so the child agent built from a title guess. Carry the + // description so buildChildDescription hands it to the coding agent + // (matching a planner-written scope, which already populates this). + ...(typeof c.description === 'string' && c.description.trim() !== '' && { description: c.description }), + // Dedup edges (Linear can surface a relation from both directions). + depends_on: [...new Set(blockers)], + }); + } + + return { kind: 'ok', parentIssueId: issue.id, children }; +} + +/** GraphQL: an issue's parent id (for the comment trigger — sub-issue → parent). */ +const ISSUE_PARENT_QUERY = ` +query IssueParent($issueId: String!) { + issue(id: $issueId) { id parent { id } } +}`; + +/** + * Fetch a sub-issue's parent issue id, for the comment trigger. A Linear + * comment names the issue it is on (the sub-issue); to find its orchestration + * we need the PARENT (orchestration_id is derived from the parent). Returns the + * parent id, or null when the issue has no parent (a top-level issue — not part + * of any orchestration) or on any fetch/auth/GraphQL failure. Never throws. + */ +export async function fetchIssueParentId( + accessToken: string, + issueId: string, + options: FetchSubIssueGraphOptions = {}, +): Promise<string | null> { + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetchImpl(LINEAR_GRAPHQL_URL, { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: ISSUE_PARENT_QUERY, variables: { issueId } }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Linear issue-parent fetch non-2xx', { status: resp.status, issue_id: issueId }); + return null; + } + const raw = (await resp.json()) as { data?: { issue?: { parent?: { id?: string } } }; errors?: unknown }; + if (raw.errors) { + logger.warn('Linear issue-parent fetch GraphQL errors', { issue_id: issueId, errors: raw.errors }); + return null; + } + return raw.data?.issue?.parent?.id ?? null; + } catch (err) { + logger.warn('Linear issue-parent fetch failed', { + issue_id: issueId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/cdk/src/handlers/shared/orchestration-base-branch.ts b/cdk/src/handlers/shared/orchestration-base-branch.ts new file mode 100644 index 000000000..fd65a1a91 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-base-branch.ts @@ -0,0 +1,93 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure base-branch selection for stacked child PRs. + * + * A released child must SEE its predecessors' code without waiting for a + * human merge. A git branch has exactly one base, so: + * - 0 predecessors (root) → branch off the repo default branch (main). + * - 1 predecessor (linear) → stack: base = that predecessor's branch + * (a true stacked PR; the child's diff shows only its own changes). + * - N predecessors (diamond) → branch off main and MERGE all + * predecessor branches into the child's branch before work starts, + * so the child sees every predecessor's code. (No human merge needed; + * starts as soon as all predecessors are task-complete.) + * + * Pure: takes the predecessors' resolved branch names + the repo default + * branch, returns the base + merge-list the release path threads to the + * agent. No I/O, so the diamond/linear/root branching is unit-testable in + * isolation. + */ + +/** A predecessor whose branch the child may stack on / merge in. */ +export interface PredecessorBranch { + readonly sub_issue_id: string; + /** The predecessor task's current head branch (persisted branch_name). */ + readonly branch_name: string; +} + +export interface BaseBranchSelection { + /** Branch the child is cut from (and its PR targets). */ + readonly base_branch: string; + /** + * Predecessor branches to merge into the child's branch before work + * (multi-predecessor only). Empty for root + linear children. + */ + readonly merge_branches: readonly string[]; + /** Shape, for logging/observability. */ + readonly shape: 'root' | 'linear' | 'diamond'; +} + +export interface SelectBaseBranchParams { + /** Predecessors of the child being released (already terminal-success). */ + readonly predecessors: readonly PredecessorBranch[]; + /** Repo default branch (root base / diamond base). Defaults to 'main'. */ + readonly defaultBranch?: string; +} + +/** + * Choose a child's base branch + any predecessor branches to merge in. + * + * Predecessors missing a usable ``branch_name`` are dropped from the + * merge/stack decision (they can't be stacked on); if that leaves a + * single-predecessor child with no branch, it degrades to a root-style + * branch off main rather than producing an invalid base. + */ +export function selectBaseBranch(params: SelectBaseBranchParams): BaseBranchSelection { + const defaultBranch = params.defaultBranch ?? 'main'; + // Dedup BEFORE the count check: two predecessors resolving to the same + // branch are one stack target, not a diamond — stack cleanly rather + // than needlessly branching off main to "merge" a single branch. + const branches = [...new Set( + params.predecessors + .map((p) => p.branch_name) + .filter((b): b is string => typeof b === 'string' && b.length > 0), + )].sort(); + + if (branches.length === 0) { + return { base_branch: defaultBranch, merge_branches: [], shape: 'root' }; + } + if (branches.length === 1) { + return { base_branch: branches[0], merge_branches: [], shape: 'linear' }; + } + // Diamond: branch off the default branch, merge every distinct + // predecessor branch in (already deduped + sorted above). + return { base_branch: defaultBranch, merge_branches: branches, shape: 'diamond' }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-factory.ts b/cdk/src/handlers/shared/orchestration-channel-factory.ts new file mode 100644 index 000000000..3bd53a796 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-factory.ts @@ -0,0 +1,147 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Registry of surface adapters, and the lookup that picks one at runtime. + * + * Event-driven paths (the reconciler, the stranded-orchestration sweep) act on an + * orchestration they LOAD rather than one they were triggered from, so they can't + * know the surface at build time — it's a property of the stored row. This maps + * that stored value to an adapter, so the engine picks a surface from data instead + * of hardcoding one. + * + * A REGISTRY rather than a switch, deliberately: a closed ``switch`` (or a closed + * ``ChannelKind`` union) means adding a tracker requires editing this file, so + * every consumer would have to merge a core change to support one more surface — + * the thing the extensibility tenet exists to prevent. Instead each adapter + * declares how to build itself, and a surface can be registered from outside this + * module entirely via {@link registerChannelFactory}. + * + * What that does and does NOT buy, stated plainly so the claim isn't read too + * broadly: a downstream surface needs no change to the engine or to this lookup, + * and resolves an adapter here from any string. It still needs its credentials + * registry named in each handler that will act on it (see + * {@link ChannelRegistryTables}) — a deployment decision about which tenants' + * secrets a Lambda may read, so deliberately explicit — and ``ChannelSource`` in + * ``types.ts`` remains a closed union shared with task records and the CLI, so a + * downstream surface can drive feedback before it can be written into a task + * record. The in-tree adapters are imported here only to seed the common cases; + * nothing about the mechanism requires it. + * + * A surface whose credentials registry isn't configured for the caller, or which + * has no registered adapter, yields ``undefined``: the caller skips feedback for + * that orchestration rather than guessing at another surface's adapter (which + * would address the wrong tenant, or fail every call). Entry points that are + * surface-specific by definition — a Linear webhook processor only ever handles + * Linear — should keep building their adapter directly; this exists for the paths + * that genuinely can't know. + */ + +import { logger } from './logger'; +import { type Channel } from './orchestration-channel'; +import { makeJiraChannel } from './orchestration-channel-jira'; +import { makeLinearChannel } from './orchestration-channel-linear'; +import { makeSlackChannel } from './orchestration-channel-slack'; + +/** + * Per-surface credentials-registry table names, keyed by channel source. An + * absent (or empty) entry means that surface isn't wired for this caller, so no + * adapter is built for it. + * + * An open map rather than named fields, for the same reason the registry itself + * is open: a new surface adds a key, not a core type change. + */ +export type ChannelRegistryTables = Readonly<Record<string, string | undefined>>; + +/** + * How to build one surface's adapter from its credentials-registry table name. + * Returning a {@link Channel} is the whole contract — everything else about the + * surface (auth, comment format, reaction vocabulary, dependency model) stays + * inside the adapter. + */ +export type ChannelFactory = (registryTableName: string) => Channel; + +/** + * The surface a stored row with NO recorded channel is treated as. Rows seeded + * before ``channel_source`` existed carry none, and Linear was the only surface + * that could have seeded them — so defaulting keeps their feedback working + * rather than silencing it. Not a statement that Linear is privileged. + */ +export const LEGACY_DEFAULT_CHANNEL_SOURCE = 'linear'; + +/** + * Registered adapters, keyed by the ``channel_source`` value that selects them. + * Mutable so a surface can register from its own module; seeded here with the + * adapters that ship in-tree. + */ +const registry = new Map<string, ChannelFactory>([ + ['linear', makeLinearChannel], + ['jira', makeJiraChannel], + // Slack is a chat surface, not a tracker: it omits the workflow-state ops + // entirely, which is what proves the engine's capability guards work against a + // genuinely different shape of surface rather than only in principle. + ['slack', makeSlackChannel], +]); + +/** + * Register (or replace) the adapter for a channel source. Lets a surface live + * entirely in its own module — including one added downstream — without editing + * the lookup below. Returns a function that restores the previous registration, + * so a test can register a fake surface without leaking into other tests. + */ +export function registerChannelFactory(source: string, factory: ChannelFactory): () => void { + const previous = registry.get(source); + registry.set(source, factory); + return () => { + if (previous) registry.set(source, previous); + else registry.delete(source); + }; +} + +/** Channel sources that currently have a registered adapter (for diagnostics). */ +export function registeredChannelSources(): readonly string[] { + return [...registry.keys()].sort(); +} + +/** + * Build the adapter for ``source`` — the orchestration row's ``channel_source``. + * + * Returns undefined when the surface has no registered adapter (e.g. a trigger + * channel with no issue-tracking surface at all, like an API or chat submission) + * or when its credentials registry isn't configured for this caller. + */ +export function channelForSource( + source: string | undefined, + tables: ChannelRegistryTables, +): Channel | undefined { + const key = source ?? LEGACY_DEFAULT_CHANNEL_SOURCE; + const factory = registry.get(key); + if (!factory) return undefined; + const registryTableName = tables[key]; + if (!registryTableName) { + // Distinguishable from "no such surface": the surface IS supported, this + // caller just can't reach its credentials — which is why its feedback goes + // silent, and worth a breadcrumb rather than an indistinguishable skip. + logger.warn('No credentials registry configured for this channel — skipping its feedback', { + channel_source: key, + }); + return undefined; + } + return factory(registryTableName); +} diff --git a/cdk/src/handlers/shared/orchestration-channel-jira.ts b/cdk/src/handlers/shared/orchestration-channel-jira.ts new file mode 100644 index 000000000..2103f0881 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-jira.ts @@ -0,0 +1,90 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Jira implementation of the surface-agnostic {@link Channel}. It maps the + * channel-neutral feedback operations onto the existing Jira comment-back helpers + * (`postIssueComment` / `reportIssueFailure`, which render the body as an Atlassian + * Document Format doc under the hood). + * + * This adapter demonstrates the capability-awareness of the interface: Jira's + * feedback surface is comment-only, so the adapter implements the REQUIRED methods + * (`postComment`, `upsertComment`, `reportFailure`) and OMITS the optional ones + * the surface can't do today — there's no reaction API, no workflow-state + * transition wired here, and the sub-issue DAG isn't derived from Jira. The + * orchestration engine checks for those methods and no-ops gracefully when a + * surface omits them, so the same engine drives Jira without any Jira-specific + * branching in the core. + * + * ``credentialsRef`` on an {@link IssueRef} is the Atlassian tenant id (``cloudId``) + * that keys the Jira token registry. + */ + +import { + postIssueComment, + reportIssueFailure, + type JiraFeedbackContext, +} from './jira-feedback'; +import { type Channel, type IssueRef } from './orchestration-channel'; + +/** + * Build a Jira {@link Channel}. ``registryTableName`` is the Jira workspace + * registry the token resolver reads; an {@link IssueRef}'s ``credentialsRef`` is + * the ``cloudId`` that keys it, and ``issueId`` is the Jira issue id-or-key. + */ +export function makeJiraChannel(registryTableName: string): Channel { + const ctxFor = (issue: IssueRef): JiraFeedbackContext => ({ + cloudId: issue.credentialsRef, + registryTableName, + }); + + return { + kind: 'jira', + + async postComment(issue, body) { + const ok = await postIssueComment(ctxFor(issue), issue.issueId, body); + // The Jira helper doesn't return the new comment id, so an edit-in-place + // isn't possible yet (see upsertComment); report success/failure only. + return ok ? { commentId: '' } : null; + }, + + async upsertComment(issue, body) { + // Jira has no comment-update helper wired today, so a repeated "upsert" + // posts a fresh comment rather than editing in place. Behaviourally safe + // (the reviewer sees the latest state); a true edit-in-place needs a Jira + // update-comment call and a returned comment id — tracked as a follow-up. + const ok = await postIssueComment(ctxFor(issue), issue.issueId, body); + return ok ? { commentId: '' } : null; + }, + + async reportFailure(issue, message) { + await reportIssueFailure(ctxFor(issue), issue.issueId, message); + }, + + // Every optional capability is intentionally omitted — Jira's wired feedback + // surface is comment-only today: no reaction API (reactToComment, + // replaceCommentReaction, replaceIssueReaction), no workflow transition + // (transitionState, revertState), no threaded-reply helper + // (postThreadedReply, upsertThreadedReply), no note sweep (sweepNotes), and + // the sub-issue DAG isn't derived from Jira (fetchChildGraph). The engine + // checks for each method and skips it, so the same orchestration core drives + // Jira without Jira-specific branching. Implementing one here is additive: + // no engine change is needed to pick it up. + }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-linear.ts b/cdk/src/handlers/shared/orchestration-channel-linear.ts new file mode 100644 index 000000000..3d9d6e491 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-linear.ts @@ -0,0 +1,190 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Linear implementation of the surface-agnostic {@link Channel}. A thin adapter: + * it maps the channel-neutral operations onto the existing Linear feedback + + * sub-issue-graph helpers, so the orchestration engine gets exactly today's + * Linear behavior through the abstraction — no behavior change, just an indirection + * the engine can also satisfy for other surfaces. + * + * Surface-specific choices made here (kept OUT of the engine): the reaction-emoji + * mapping, the markdown comment format (Linear-native), and reading the sub-issue + * DAG from Linear's `blocks` relations. + */ + +import { + EMOJI_FAILURE, + EMOJI_NEEDS_INPUT, + EMOJI_STARTED, + EMOJI_SUCCESS, + postIssueComment, + reactToComment as addCommentReaction, + replyToComment, + reportIssueFailure, + revertIssueToNotStarted, + swapCommentReaction, + swapIssueReaction, + sweepDecompositionNotes, + transitionIssueState, + upsertStatusComment, + upsertThreadedReply as upsertLinearThreadedReply, + type LinearFeedbackContext, +} from './linear-feedback'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { fetchSubIssueGraph } from './linear-subissue-fetch'; +import { logger } from './logger'; +import { + type Channel, + type ChannelSubIssueNode, + type IssueRef, + type Reaction, + type StateIntent, +} from './orchestration-channel'; + +/** Map the engine's small reaction vocabulary to Linear's emoji names. This is + * the one place the surface's reaction set is known. */ +const REACTION_EMOJI: Record<Reaction, string> = { + started: EMOJI_STARTED, + succeeded: EMOJI_SUCCESS, + failed: EMOJI_FAILURE, + needs_input: EMOJI_NEEDS_INPUT, +}; + +/** + * Map the engine's state intent onto how Linear is asked for that state: the + * semantic state TYPE plus the state NAMES to prefer within it. + * + * The name preference is load-bearing, not decoration. Linear models both "In + * Progress" and "In Review" as type ``started``, so type alone cannot say which + * one the engine meant — without the name the transition would resolve to + * whichever the team happens to order first and the two intents would collapse + * into the same move. Teams that lack the preferred name still resolve to a + * sensible state of the right type. + */ +const STATE_TARGET: Record<StateIntent, { type: 'started' | 'completed'; prefer: readonly string[] }> = { + started: { type: 'started', prefer: ['In Progress'] }, + in_review: { type: 'started', prefer: ['In Review'] }, + completed: { type: 'completed', prefer: [] }, +}; + +/** + * Build a Linear {@link Channel}. ``registryTableName`` is the workspace-registry + * table the token resolver reads; an {@link IssueRef}'s ``credentialsRef`` is the + * Linear workspace (organization) id that keys it. The feedback helpers each take + * a {@link LinearFeedbackContext} built from that pair. + */ +export function makeLinearChannel(registryTableName: string): Channel { + const ctxFor = (issue: IssueRef): LinearFeedbackContext => ({ + linearWorkspaceId: issue.credentialsRef, + registryTableName, + }); + + return { + kind: 'linear', + + async postComment(issue, body) { + const res = await postIssueComment(ctxFor(issue), issue.issueId, body); + // postIssueComment doesn't return the new comment id, so callers that need + // to edit later use upsertComment instead. Report success/failure only. + return res.ok ? { commentId: '' } : null; + }, + + async upsertComment(issue, body, existing) { + const id = await upsertStatusComment(ctxFor(issue), issue.issueId, body, existing?.commentId); + return id ? { commentId: id } : null; + }, + + async reportFailure(issue, message) { + await reportIssueFailure(ctxFor(issue), issue.issueId, message); + }, + + async reactToComment(comment, issue, reaction) { + // ADD-only (no delete of prior markers) — the instant receipt ack. + return addCommentReaction(ctxFor(issue), comment.commentId, REACTION_EMOJI[reaction]); + }, + + async replaceCommentReaction(comment, issue, reaction) { + return swapCommentReaction(ctxFor(issue), comment.commentId, REACTION_EMOJI[reaction]); + }, + + async replaceIssueReaction(issue, reaction) { + return swapIssueReaction(ctxFor(issue), issue.issueId, REACTION_EMOJI[reaction]); + }, + + async transitionState(issue, intent, options) { + const target = STATE_TARGET[intent]; + return transitionIssueState( + ctxFor(issue), + issue.issueId, + target.type, + target.prefer, + options?.allowRegression ?? false, + ); + }, + + async revertState(issue) { + return revertIssueToNotStarted(ctxFor(issue), issue.issueId); + }, + + async postThreadedReply(issue, parent, body) { + const id = await replyToComment(ctxFor(issue), issue.issueId, parent.commentId, body); + return id ? { commentId: id } : null; + }, + + async upsertThreadedReply(issue, parent, body, existing, options) { + const id = await upsertLinearThreadedReply( + ctxFor(issue), + issue.issueId, + parent.commentId, + body, + existing?.commentId, + { + preservePreview: options?.preservePreview ?? false, + skipIfSettled: options?.skipIfSettled ?? false, + repairIfOverwritten: options?.repairIfOverwritten ?? false, + }, + ); + return id ? { commentId: id } : null; + }, + + async sweepNotes(issue, keep) { + return sweepDecompositionNotes(ctxFor(issue), issue.issueId, keep?.commentId); + }, + + async fetchChildGraph(parent): Promise<readonly ChannelSubIssueNode[]> { + const resolved = await resolveLinearOauthToken(parent.credentialsRef, registryTableName); + if (!resolved?.accessToken) { + logger.warn('Linear channel: no token to fetch sub-issue graph', { + issue_id: parent.issueId, + }); + return []; + } + const graph = await fetchSubIssueGraph(resolved.accessToken, parent.issueId); + if (graph.kind !== 'ok') return []; + // Map Linear's SubIssueNode (blocks-derived depends_on) to the neutral shape. + return graph.children.map((n) => ({ + issueId: n.id, + ...(n.identifier !== undefined && { displayId: n.identifier }), + ...(n.title !== undefined && { title: n.title }), + dependsOn: n.depends_on, + })); + }, + }; +} diff --git a/cdk/src/handlers/shared/orchestration-channel-slack.ts b/cdk/src/handlers/shared/orchestration-channel-slack.ts new file mode 100644 index 000000000..436724cfd --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel-slack.ts @@ -0,0 +1,275 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Slack implementation of the surface-agnostic {@link Channel}. + * + * Slack is deliberately the SECOND-most-different surface available: it is a chat + * product, not an issue tracker, so it exercises the interface's capability + * gating for real rather than hypothetically. Two mappings carry the weight: + * + * - **A "thread" is the issue.** ``IssueRef.issueId`` is ``<channel>:<thread_ts>`` + * — the conversation an orchestration reports into. Slack has no issue object, + * so the thread is the durable thing a panel can live in. + * - **A message ts is the comment id.** ``CommentRef.commentId`` is a message + * ``ts``; ``chat.update`` edits it, which is what lets one status panel mature + * in place instead of streaming new messages. + * + * ``credentialsRef`` is the Slack ``team_id``, which keys the per-workspace bot + * token secret (``bgagent/slack/<team_id>``) — the same lookup the Slack event + * handlers use. + * + * DELIBERATELY OMITTED — Slack has no workflow state, so there is nothing for + * ``transitionState`` / ``revertState`` to move. They are absent rather than + * stubbed to a no-op, so the engine's capability guards skip them: a silent + * success would claim the platform mirrored a state it never did, and the panel + * is then the only place the epic's progress is visible. ``sweepNotes`` is also + * omitted (deleting other messages in a shared channel is a blunt instrument that + * needs its own product decision, not a quiet default), as is ``fetchChildGraph`` + * — Slack has no dependency model to read a DAG from, so a Slack-triggered + * orchestration supplies its graph declaratively. + */ + +import { logger } from './logger'; +import { + type Channel, + type IssueRef, + type Reaction, +} from './orchestration-channel'; +import { slackFetch, slackFetchTs } from './slack-api'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './slack-verify'; + +/** + * Map the engine's reaction vocabulary to Slack emoji names. The one place + * Slack's reaction set is known — the engine only ever names a {@link Reaction}. + */ +const REACTION_EMOJI: Record<Reaction, string> = { + started: 'eyes', + succeeded: 'white_check_mark', + failed: 'x', + needs_input: 'question', +}; + +/** Every emoji this adapter may have applied, so a "replace" clears its own + * prior markers without touching a human's reaction. */ +const OWN_EMOJI: readonly string[] = Object.values(REACTION_EMOJI); + +/** Separator between the channel id and thread ts inside an ``issueId``. A colon + * can't appear in either part, so the split is unambiguous. */ +const THREAD_REF_SEPARATOR = ':'; + +/** Build the ``issueId`` for a Slack thread. Exported so a Slack-side seeding + * path composes the ref the same way this adapter parses it. */ +export function slackThreadRef(channelId: string, threadTs: string): string { + return `${channelId}${THREAD_REF_SEPARATOR}${threadTs}`; +} + +/** Split an ``issueId`` back into its channel + thread ts. */ +function parseThreadRef(issueId: string): { channel: string; threadTs: string } | null { + const at = issueId.indexOf(THREAD_REF_SEPARATOR); + if (at <= 0 || at === issueId.length - 1) return null; + return { channel: issueId.slice(0, at), threadTs: issueId.slice(at + 1) }; +} + +/** + * Build a Slack {@link Channel}. ``secretPrefix`` is the Secrets Manager prefix + * the per-workspace bot tokens live under; an {@link IssueRef}'s + * ``credentialsRef`` is the ``team_id`` that completes it. + * + * Signature matches the other adapters (one string) so it registers as a + * {@link ChannelFactory} without special-casing. + */ +export function makeSlackChannel(secretPrefix: string = SLACK_SECRET_PREFIX): Channel { + /** Resolve the workspace bot token, or null when the workspace isn't installed. */ + const tokenFor = async (issue: IssueRef): Promise<string | null> => { + const token = await getSlackSecret(`${secretPrefix}${issue.credentialsRef}`); + if (!token) { + logger.warn('Slack channel: no bot token for workspace — skipping feedback', { + team_id: issue.credentialsRef, + }); + } + return token; + }; + + /** Resolve the token AND the thread the ref names; null if either is missing. */ + const contextFor = async (issue: IssueRef) => { + const thread = parseThreadRef(issue.issueId); + if (!thread) { + logger.warn('Slack channel: issue ref is not a <channel>:<thread_ts> pair', { + issue_id: issue.issueId, + }); + return null; + } + const token = await tokenFor(issue); + return token ? { token, ...thread } : null; + }; + + return { + kind: 'slack', + + async postComment(issue, body) { + const ctx = await contextFor(issue); + if (!ctx) return null; + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + async upsertComment(issue, body, existing) { + const ctx = await contextFor(issue); + if (!ctx) return null; + if (existing?.commentId) { + // Edit in place — this is what makes the maturing panel one message + // rather than a stream. chat.update echoes the ts it edited. + const ts = await slackFetchTs(ctx.token, 'chat.update', { + channel: ctx.channel, + ts: existing.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + } + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + async reportFailure(issue, message) { + const ctx = await contextFor(issue); + if (!ctx) return; + await slackFetch(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: ctx.threadTs, + text: message, + }); + }, + + async reactToComment(comment, issue, reaction) { + // ADD-only — the instant receipt ack, which must not disturb any marker + // already present. + const ctx = await contextFor(issue); + if (!ctx) return false; + return slackFetch(ctx.token, 'reactions.add', { + channel: ctx.channel, + timestamp: comment.commentId, + name: REACTION_EMOJI[reaction], + }); + }, + + async replaceCommentReaction(comment, issue, reaction) { + const ctx = await contextFor(issue); + if (!ctx) return false; + // Slack has no atomic swap, so clear this adapter's OWN markers then add + // the target. Scoped to OWN_EMOJI so a human's reaction survives; a + // `no_reaction` error on a marker that isn't there is benign and treated + // as success by slackFetch. + const target = REACTION_EMOJI[reaction]; + const stale: string[] = []; + for (const emoji of OWN_EMOJI) { + if (emoji === target) continue; + const removed = await slackFetch(ctx.token, 'reactions.remove', { + channel: ctx.channel, + timestamp: comment.commentId, + name: emoji, + }); + if (!removed) stale.push(emoji); + } + const added = await slackFetch(ctx.token, 'reactions.add', { + channel: ctx.channel, + timestamp: comment.commentId, + name: target, + }); + if (stale.length > 0) { + // Reporting the add alone would claim the promised end state — ONE marker + // — while the message still carries a contradictory one ("saw it" beside + // "done"), and the contradiction is precisely what a caller checks this + // result to rule out. + logger.warn('Slack channel: a stale status marker could not be removed', { + event: 'slack_channel.reaction_replace_incomplete', + message_ts: comment.commentId, + target, + stale, + }); + } + return added && stale.length === 0; + }, + + async replaceIssueReaction(issue, reaction) { + // The "issue" is a thread, so its at-a-glance marker goes on the thread's + // root message — the closest equivalent to reacting to an issue. + const thread = parseThreadRef(issue.issueId); + if (!thread) return false; + return this.replaceCommentReaction!( + { commentId: thread.threadTs }, + issue, + reaction, + ); + }, + + async postThreadedReply(issue, parent, body) { + const ctx = await contextFor(issue); + if (!ctx) return null; + // Slack threads are one level deep: a reply goes to the thread the parent + // belongs to. Using the parent's own ts as thread_ts starts a thread on it + // when the parent is a root, and stays in-thread otherwise. + const ts = await slackFetchTs(ctx.token, 'chat.postMessage', { + channel: ctx.channel, + thread_ts: parent.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + }, + + // The convergence options (preview preservation, settle checks, outcome + // repair) are deliberately not implemented here: each needs a read of the + // current message text, and this adapter posts and edits without reading + // back. Ignoring them is what the interface specifies for a surface that + // can't, and it costs nothing today because no multi-writer maturing reply + // runs on Slack — the orchestration engine's late-progress race is between + // Lambdas that write to the issue surface. Anything wiring a maturing reply + // onto Slack needs to honour them first, via `conversations.replies`. + async upsertThreadedReply(issue, parent, body, existing) { + const ctx = await contextFor(issue); + if (!ctx) return null; + if (existing?.commentId) { + const ts = await slackFetchTs(ctx.token, 'chat.update', { + channel: ctx.channel, + ts: existing.commentId, + text: body, + }); + return ts ? { commentId: ts } : null; + } + return this.postThreadedReply!(issue, parent, body); + }, + + // transitionState / revertState: omitted — Slack has no workflow state. + // sweepNotes: omitted — bulk-deleting messages in a shared channel needs its + // own product decision. + // fetchChildGraph: omitted — no dependency model; graphs arrive declaratively. + } satisfies Channel as Channel; +} + +/** Re-exported so a caller can name the reaction set without importing Slack + * internals — used by the adapter's own tests. */ +export const SLACK_OWN_REACTION_EMOJI = OWN_EMOJI; diff --git a/cdk/src/handlers/shared/orchestration-channel.ts b/cdk/src/handlers/shared/orchestration-channel.ts new file mode 100644 index 000000000..4b924f781 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-channel.ts @@ -0,0 +1,271 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Surface-agnostic channel abstraction for sub-issue orchestration. + * + * The orchestration engine (discovery, release, reconcile, rollup) drives issue + * feedback — panel comments, reactions, state transitions, failure notices — and + * reads the sub-issue graph. Historically it called the Linear API directly, so + * the engine was Linear-only. This interface lets the engine speak to ANY + * issue-tracking surface (Linear today, Jira next — a Jira comment-back path + * already exists) without naming one: the engine holds a `Channel` and calls + * these methods; each surface ships an adapter that implements them. + * + * Design boundary — what stays surface-SPECIFIC (behind the adapter): + * - **Dependency/blocking relations.** Linear models a sub-issue DAG with native + * `blocks` relations; another surface may have no equivalent and derive the + * graph differently. So `fetchChildGraph` is the adapter's job — the engine + * only consumes the resulting DAG. + * - **Comment formatting.** One surface takes markdown, another a structured + * document format. The adapter renders; the engine passes a plain-text body. + * - **Reaction vocabulary.** Each surface has its own reaction set; the engine + * speaks the small {@link Reaction} enum and the adapter maps it. + * - **Auth.** Credentials live behind an opaque `credentialsRef` the adapter + * resolves; the engine never touches surface auth. + * + * Capability-awareness: not every surface supports every operation (e.g. a + * surface may have no reactions or no workflow-state transitions). Those methods + * are OPTIONAL; the engine checks for presence and no-ops gracefully when a + * surface can't do them, so the core stays uniform. + * + * Every method is best-effort and must not throw — feedback is advisory and must + * never gate the orchestration itself (mirrors the existing per-surface helpers). + */ + +/** + * Which surface an adapter talks to — for logging and metrics only. The engine + * never branches on it, which is why this is an open string rather than a union + * of the surfaces that happen to exist today: a closed union here would mean + * adding a surface requires editing this file, i.e. every consumer merging a + * core change to support one more tracker. Adapters live outside this module and + * name themselves. + */ +export type ChannelKind = string; + +/** + * A reference to an issue on some surface, plus the opaque credentials handle + * the adapter needs to act on it. The engine treats every field as opaque. + */ +export interface IssueRef { + /** The surface's issue identifier (Linear issue UUID, Jira issue key, …). */ + readonly issueId: string; + /** Opaque credentials handle the adapter resolves (e.g. a workspace id that + * keys an OAuth-token registry row). The engine never interprets it. */ + readonly credentialsRef: string; + /** Optional human-facing id for display in panels (e.g. ``ENG-42``). */ + readonly displayId?: string; +} + +/** A reference to a comment the adapter created, so it can be edited/reacted to. */ +export interface CommentRef { + readonly commentId: string; +} + +/** The small vocabulary of reactions the engine uses; the adapter maps each to + * the surface's own reaction (emoji, etc.). */ +export type Reaction = 'started' | 'succeeded' | 'failed' | 'needs_input'; + +/** + * Workflow-state intent the engine expresses; the adapter maps it to the + * surface's actual states. Three intents rather than two because the engine + * genuinely distinguishes "work is running" from "work is done, awaiting human + * review" — on some surfaces (Linear included) both are the same underlying + * state *category*, so collapsing them would make the engine unable to say + * which one it meant: + * - ``started`` ≈ In Progress — a child was released and is running. + * - ``in_review`` ≈ In Review — work finished, a human still has to merge it. + * - ``completed`` ≈ Done — the surface's terminal state. + */ +export type StateIntent = 'started' | 'in_review' | 'completed'; + +/** Options for {@link Channel.transitionState}. */ +export interface TransitionOptions { + /** + * Allow a move that the adapter would otherwise refuse as backward *within + * the same state category* — the deliberate re-open, when a settled epic + * gains a new or re-run child and must go from "awaiting review" back to + * "running". Without this the move is silently dropped as a regression. + * Demotion across categories (something already terminal, e.g. a human + * marked it done) stays blocked regardless. + */ + readonly allowRegression?: boolean; +} + +/** Options for {@link Channel.upsertThreadedReply}. */ +export interface ThreadedReplyOptions { + /** + * Carry over a preview/deploy link that a SEPARATE async writer may already + * have appended to this reply. Two writers converge on one comment (the + * orchestration settle and the preview-capture callback), and whichever + * lands second would otherwise clobber the other's text. When set, the + * adapter reads the current body and preserves that segment. + */ + readonly preservePreview?: boolean; + /** + * Set on a PROGRESS render to yield to an outcome that has already landed. + * Progress and terminal states are written by independent paths, and the + * progress one can be delivered late — overwriting a settled reply with + * "working" would contradict both the outcome and the surface's own markers. + * A surface that cannot read a body back simply ignores this. + */ + readonly skipIfSettled?: boolean; + /** + * Set on a TERMINAL render to check the outcome survived, and restore it if a + * concurrently-delivered progress render landed on top. + * + * The counterpart to {@link skipIfSettled}, and needed because that check is a + * read followed by a separate write: it narrows the window without closing it. + * A surface offering no conditional/versioned update (Linear does not) cannot + * close it at all, so the writer that holds the body worth keeping verifies + * afterwards instead. Adapters without a body read simply ignore this. + */ + readonly repairIfOverwritten?: boolean; +} + +/** A node in the sub-issue graph, as the adapter surfaces it to the engine. */ +export interface ChannelSubIssueNode { + readonly issueId: string; + readonly displayId?: string; + readonly title?: string; + /** issueIds this node depends on (blocked-by). How the adapter derives this + * is surface-specific (Linear: `blocks` relations); the engine just reads it. */ + readonly dependsOn: readonly string[]; +} + +/** + * A surface adapter. The orchestration engine holds one of these and never names + * a concrete surface. Implementations: {@link makeLinearChannel} today; a Jira + * adapter unifies the existing Jira comment-back onto the same interface. + */ +export interface Channel { + readonly kind: ChannelKind; + + // --- feedback (every surface must support these) --- + + /** Post a comment on an issue; returns a ref so it can be edited later, or + * null if the post failed (best-effort). ``body`` is plain text/markdown; the + * adapter renders it for the surface. */ + postComment(issue: IssueRef, body: string): Promise<CommentRef | null>; + + /** Edit a previously-posted comment in place (the maturing status panel). When + * no ref is given, create + return one. Returns the (new or existing) ref, or + * null on failure. */ + upsertComment(issue: IssueRef, body: string, existing?: CommentRef): Promise<CommentRef | null>; + + /** Post a failure notice on the issue (❌ + message). Best-effort. */ + reportFailure(issue: IssueRef, message: string): Promise<void>; + + // --- optional per-surface capabilities (engine no-ops if absent) --- + + /** + * ADD a reaction to a comment, leaving any existing reaction in place. This is + * the instant "I saw your request" ack, set the moment a comment arrives — + * before any work exists to report on, so there is nothing to replace yet. + * + * Deliberately distinct from {@link replaceCommentReaction}: adding is not the + * same as replacing, and conflating them would either strip a marker the ack + * path never meant to touch, or leave two contradictory markers on a settled + * comment. Returns true if the reaction landed. + */ + reactToComment?(comment: CommentRef, issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Make ``reaction`` the SOLE bot reaction on a comment, clearing the bot's own + * prior markers first — used when work settles, so the comment shows one + * outcome rather than an accumulated pile ("saw it" + "done" at once). + * A human's reactions are never touched. Idempotent: re-running converges on + * the same single marker. + * + * Returns true only when that end state was actually reached: the target is + * present AND no prior bot marker was left behind. Reporting the add alone + * would claim success while the surface still shows two contradictory markers — + * and a caller checking this result is checking for exactly that contradiction, + * since it is what makes a settled item look unsettled. + */ + replaceCommentReaction?(comment: CommentRef, issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Make ``reaction`` the sole bot reaction on the ISSUE itself (not a comment) — + * the at-a-glance status marker on a parent epic or a child, which matures + * across separate invocations. Same replace-only-our-own-markers contract and + * same all-or-nothing result as {@link replaceCommentReaction}. + */ + replaceIssueReaction?(issue: IssueRef, reaction: Reaction): Promise<boolean>; + + /** + * Move the issue to a workflow state. Optional — a surface without a + * transition API omits this. Adapters must refuse to move an issue BACKWARD + * (something a human or automation already advanced stays advanced), except + * where ``options.allowRegression`` opts into a same-category re-open. + * Returns true only on a confirmed transition (false when skipped as + * already-there or backward). + */ + transitionState?(issue: IssueRef, intent: StateIntent, options?: TransitionOptions): Promise<boolean>; + + /** + * Move the issue BACK to a not-started state. The one sanctioned backward + * move: work the engine had marked as running has stopped without succeeding + * (a plan now awaits a human's approval, or a child failed), so leaving it + * "in progress" would misreport a run that isn't happening. Adapters must + * guard this to only demote an issue still in the state the engine itself + * set, never one a human has since advanced or pulled back. Returns true only + * on a confirmed move. + */ + revertState?(issue: IssueRef): Promise<boolean>; + + /** + * Post a threaded reply beneath an existing comment. Unlike editing, a reply + * notifies and reads as a conversation turn under the original request. + * Returns a ref to the new reply, or null on failure. + */ + postThreadedReply?(issue: IssueRef, parent: CommentRef, body: string): Promise<CommentRef | null>; + + /** + * The MATURING threaded reply: edit ``existing`` in place when given, else + * create a reply under ``parent``. One reply that matures through its + * lifecycle, instead of a new comment per transition. Returns the (new or + * existing) ref, or null on failure. + */ + upsertThreadedReply?( + issue: IssueRef, + parent: CommentRef, + body: string, + existing?: CommentRef, + options?: ThreadedReplyOptions, + ): Promise<CommentRef | null>; + + /** + * Delete the engine's own transient planning notes from an issue thread, + * keeping ``keep`` if given. Interim notes are posted fire-and-forget from + * many places, so once a plan settles the thread needs collapsing to just the + * outcome. Adapters must scope deletion to the bot's own notes — never a + * human's comment, and never the durable status panel. Returns how many were + * removed. + */ + sweepNotes?(issue: IssueRef, keep?: CommentRef): Promise<number>; + + // --- graph (surface-specific derivation, uniform result) --- + + /** Read the sub-issue graph rooted at a parent, with dependency edges resolved + * however the surface expresses them. Optional — a surface with no native + * sub-issue/dependency model omits this and the engine falls back to a + * declarative graph source. */ + fetchChildGraph?(parent: IssueRef): Promise<readonly ChannelSubIssueNode[]>; +} diff --git a/cdk/src/handlers/shared/orchestration-comment-trigger.ts b/cdk/src/handlers/shared/orchestration-comment-trigger.ts new file mode 100644 index 000000000..368fd438d --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-comment-trigger.ts @@ -0,0 +1,394 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure logic for the comment trigger that iterates on an existing PR. A + * reviewer who wants + * a sub-issue's PR changed mentions ``@bgagent`` in a Linear comment on that + * sub-issue; the platform runs a ``coding/pr-iteration-v1`` task on the + * sub-issue's PR (and the reconciler then cascades the re-stack to dependents). + * + * This module decides — from a comment body alone — whether the comment is an + * instruction for the agent and what the instruction text is. Kept pure (no + * I/O, no Linear/AWS types) so the mention parsing is unit-testable and reused + * regardless of how the comment arrives. The processor does the I/O (resolve + * sub-issue → orchestration → PR, spawn the task). + */ + +/** The mention token that turns a Linear comment into an agent instruction. */ +export const MENTION_TOKEN = '@bgagent'; + +export interface CommentTrigger { + /** True when the comment is an explicit instruction for the agent. */ + readonly triggered: boolean; + /** + * The instruction text with the mention token stripped, trimmed. Empty when + * not triggered, or when the mention had no accompanying text (the caller + * treats an empty instruction as "address the latest review" — still valid). + */ + readonly instruction: string; +} + +/** + * Decide whether a comment body is an ``@bgagent`` instruction, and extract + * the instruction text. + * + * Rules (deliberately strict to avoid false-positives on human discussion and, + * critically, on the agent's OWN progress comments which never contain the + * mention token): + * - Must contain ``@bgagent`` (case-insensitive), as a token boundary so + * ``@bgagentx`` / an email-like ``foo@bgagent.io`` do NOT trigger. + * - The instruction is everything after stripping the token (all occurrences), + * collapsed/trimmed. A bare ``@bgagent`` with no text still triggers + * (instruction === ''). + */ +export function parseCommentTrigger(body: string | undefined | null): CommentTrigger { + if (!body) return { triggered: false, instruction: '' }; + // SELF-COMMENT GUARD: the bot's OWN rendered comments must NEVER trigger it, + // or it talks to itself forever. This happened in practice — the + // disambiguation reply embedded a literal "@bgagent ENG-123: …" example, so + // the reply re-matched the mention and spawned another reply, ~50 deep. + // The agent's progress comments are also bot-authored. + // Cheapest robust signal that needs no actor-identity config: a body that + // STARTS WITH one of our own template markers is ours, not a user + // instruction. (Linear strips a leading emoji to its own line sometimes, so + // we test the trimmed start.) Keep this list in sync with the rendered + // comment prefixes (panel, acks, disambiguation, agent progress). + if (isBotAuthoredComment(body)) return { triggered: false, instruction: '' }; + // Token-boundary match: @bgagent not immediately followed by a word char or + // a '.' (so it won't fire on @bgagentbot or an @bgagent.io address). + const re = /@bgagent(?![\w.])/gi; + if (!re.test(body)) return { triggered: false, instruction: '' }; + const instruction = body.replace(/@bgagent(?![\w.])/gi, ' ').replace(/\s+/g, ' ').trim(); + return { triggered: true, instruction }; +} + +/** + * Markers that begin a comment the BOT itself rendered (panel, acks, + * disambiguation reply, agent progress). A comment starting with any of these + * is never a human instruction — used to break self-trigger loops. + */ +const BOT_COMMENT_PREFIXES = [ + '👋', // disambiguation "which sub-issue?" reply + '✅', // "✅ Updated — PR #…" ack / "✅ **ABCA orchestration complete**" panel + '❌', // failure reply + '⚠️', // "finished with failures" panel + '🔄', // in-progress panel + '🤖', // agent progress ("🤖 Starting…") + '🖼️', // preview screenshot comment + '🔗', // "PR opened" / combined-PR + '🗂️', // plan proposal / decomposition notes (embed literal "@bgagent approve") + '💬', // maturing-reply "answered" state (a no-change/question iteration) + '👀', // instant "on it" ack reply (posted at trigger time) +] as const; + +/** True when ``body`` is one of the bot's own rendered comments (loop guard). */ +export function isBotAuthoredComment(body: string): boolean { + const trimmed = body.trimStart(); + return BOT_COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)); +} + +/** + * Near-miss mention handles. A reviewer who + * addresses the bot by the WRONG handle (most often ``@abca`` — confusing the + * trigger LABEL for the mention handle — or a boundary-miss like ``@bgagentx``) + * previously fell into a silent black hole: {@link parseCommentTrigger} returned + * ``triggered: false`` and the webhook dropped the comment with no reply and no + * reaction, so the reviewer had no idea their instruction was never seen. + * + * This is a DELIBERATELY NARROW allowlist of handles that are clearly meant for + * THIS bot but aren't the exact ``@bgagent`` token — so the near-miss nudge never + * fires on a real teammate mention. Generic words (``@agent``/``@bot``) are + * intentionally EXCLUDED (they can be real usernames); only bot-specific + * near-misses qualify. Matching is done by {@link detectNearMissMention}. + */ +const NEAR_MISS_MENTION_PATTERNS: readonly RegExp[] = [ + // @abca (+ optional :suffix like @abca:decompose) — the label-name confusion. + /@abca\b/i, + // @bgagent immediately followed by a word char — a boundary-miss that + // parseCommentTrigger's `@bgagent(?![\w.])` deliberately does NOT trigger + // (@bgagentbot, @bgagentx). NOT `@bgagent ` (a space → real trigger) nor + // `@bgagent.` (an email-like foo@bgagent.io → not a mention). + /@bgagent\w/i, + // Hyphen/underscore variants. The separator is REQUIRED (not optional) so these + // match @bg-agent / @bg_agent but NOT the canonical @bgagent (which parses as a + // real trigger, not a near-miss) — an optional separator would wrongly flag it. + /@bg[-_]agent\b/i, + // @bgbot / @bg-bot / @bg_bot — a plausible shorthand. Distinct from @bgagent. + /@bg[-_]?bot\b/i, + // The spelled-out name — @backgroundagent / @background-agent. Distinct too. + /@background[-_]?agent\b/i, +]; + +/** + * Detect a NEAR-MISS bot mention: the reviewer clearly meant to + * address the bot but used the wrong handle (``@abca``, ``@bgagentx``, …), so + * {@link parseCommentTrigger} didn't fire. Returns true so the caller can nudge + * ("I answer to ``@bgagent``") instead of silently dropping the comment. + * + * Only consulted in the NOT-triggered branch (a real ``@bgagent`` never reaches + * here). Skips the bot's own comments (never nudge ourselves). Strict allowlist + * ({@link NEAR_MISS_MENTION_PATTERNS}) so it can't misfire on human discussion or + * a genuine teammate mention. + */ +export function detectNearMissMention(body: string | undefined | null): boolean { + if (!body) return false; + if (isBotAuthoredComment(body)) return false; + return NEAR_MISS_MENTION_PATTERNS.some((re) => re.test(body)); +} + +/** + * Build the task description handed to ``coding/pr-iteration-v1`` from the + * comment instruction. When the reviewer left explicit text, that IS the + * instruction; when they only mentioned ``@bgagent`` with no text, fall back + * to a generic "address the latest review feedback on this PR" so the agent + * still has a directive. + */ +export function buildIterationInstruction(trigger: CommentTrigger): string { + if (trigger.instruction.length > 0) return trigger.instruction; + return 'Address the latest review feedback on this pull request.'; +} + +/** + * Does an ``@bgagent`` comment read as a RETRY request — + * "re-run the work that failed" — rather than a change instruction? The failure + * panel tells the user "reply here to try again", so a bare ``@bgagent retry`` / + * "try again" / "re-run" must route to the epic-retry machinery (reset + re-run + * the failed/skipped children), NOT to the disambiguation/iteration path, which + * either dead-ended or looped and so never actually re-ran anything. + * + * Conservative, mirroring {@link parsePlanVerdict}'s discipline: only fires when + * the instruction is a SHORT (≤{@link MAX_VERDICT_WORDS}-word) comment led by (or + * consisting of) a retry phrase — so "retry the footer but change the color and …" + * (a substantive edit that happens to start with "retry") is NOT swallowed as a + * bare retry; it falls through to the normal iterate/revise path. An empty + * instruction (bare ``@bgagent``) is NOT a retry — that stays "address the latest + * review" per {@link buildIterationInstruction}. + */ +const RETRY_PHRASES = [ + 'retry', 'retries', 'try again', 'rerun', 're-run', 're run', 'run again', 'run it again', +] as const; +export function parseRetryIntent(instruction: string): boolean { + const text = instruction.replace(/[*_`>]/g, ' ').trim().toLowerCase().replace(/\s+/g, ' '); + if (!text) return false; + if (text.split(' ').length > MAX_VERDICT_WORDS) return false; + const firstWord = text.split(/[\s.,!?—–-]+/)[0]; + if (firstWord === 'retry' || firstWord === 'rerun') return true; + return RETRY_PHRASES.some((p) => hasPhrase(text, p)); +} + +/** The command words ABCA recognises in an ``@bgagent`` comment on an epic. + * Today there is exactly one — ``retry``. Surfaced in the epic + * disambiguation/fallback reply so the user always sees what they CAN type + * (rather than us trying to guess a typo). Extend this together with + * {@link RETRY_PHRASES} when a new command is added. */ +export const KNOWN_EPIC_COMMANDS = ['retry'] as const; + +/** + * The verdict of an ``@bgagent`` comment on a pending decomposition + * plan — the proposed sub-issue breakdown of a plain issue, awaiting a + * reviewer's go-ahead before any work starts. + * ``none`` means the comment is an ordinary change instruction (routes to + * the revise loop). ``ambiguous`` means an unqualified negation ("no", "no + * thanks", "don't approve") that is NOT a clear discard — the processor nudges + * the reviewer to pick (approve / reject / change) rather than destroy the plan. + */ +export type PlanVerdict = 'approve' | 'reject' | 'none' | 'ambiguous'; + +/** + * Natural ways a reviewer signals "go ahead" on a pending plan. Real people don't + * type the exact keyword — a strict ``approve``-only parser silently swallowed + * "lgtm", "yes go ahead", "👍", "looks good", each confirmed against real + * reviewer comments. Multi-word phrases are matched as phrases; single tokens as + * whole words. + */ +const APPROVE_PHRASES = [ + 'approve', 'approved', 'approves', 'lgtm', 'sgtm', 'yes', 'yep', 'yeah', 'yup', + 'ok', 'okay', 'sure', 'proceed', 'accept', 'accepted', 'confirm', 'confirmed', + 'ship it', 'shipit', 'do it', 'go ahead', 'go for it', 'sounds good', + 'looks good', 'looks great', 'send it', '+1', +] as const; +/** + * EXPLICIT, unambiguous "kill it" words — these DISCARD the pending plan (the one + * destructive, irreversible action in the flow: a discarded plan is gone, whereas + * an approved plan's sub-issues can still be closed). A discard therefore demands + * explicit intent. A SOFT negation ("no", "don't") is deliberately NOT here — see + * {@link SOFT_NEGATION_PHRASES}: it is ambiguous between "discard" and "change it", + * so it must never silently destroy the plan. + */ +const EXPLICIT_REJECT_PHRASES = [ + 'reject', 'rejected', 'rejects', 'cancel', 'cancelled', 'canceled', + 'stop', 'discard', 'abort', +] as const; +/** + * SOFT negations. On their own — or as pure negativity ("no, looks wrong") — these + * are AMBIGUOUS: "no" could mean "discard it" or "no, change it". Rather than + * guess-and-destroy, we nudge the reviewer to pick. When a soft negation is + * FOLLOWED BY a substantive change instruction ("no, make it 3 tasks") it is a + * REVISE. This closes a destructive bug seen in practice: "no, just 2 tasks" was + * parsed as reject and DELETED the pending plan; the earlier word-count guard + * only saved LONG negations, not a short one carrying an instruction. + */ +const SOFT_NEGATION_PHRASES = [ + 'no', 'nope', 'nah', "don't", 'do not', 'dont', '-1', + // 'not' was once MISSING here, so "not sure" / "not ok" / "not approved" + // fell through to APPROVE (they contain approve words). As a soft negation it + // now routes to ambiguous (nudge) or, with a change instruction, revise — + // never a silent approval against the reviewer's "not". + 'not', +] as const; +/** + * Change-instruction signals that mark a soft-negation comment as a REVISE (re-plan + * from the feedback) rather than a bare negation. An imperative change verb + * ("make", "split", "merge", "keep", …) or a numeric-count directive ("2 tasks", + * "3 sub-issues"). Best-effort: an unrecognized instruction falls back to a NUDGE + * (safe — asks the reviewer to rephrase; the choice between revise and nudge is + * purely UX, since BOTH are non-destructive — only reject destroys). + */ +const CHANGE_VERBS = [ + 'make', 'split', 'merge', 'combine', 'consolidate', 'add', 'remove', 'drop', + 'delete', 'keep', 'change', 'rename', 'reorder', 'move', 'reduce', 'increase', + 'use', 'separate', 'group', 'break', 'expand', 'swap', 'replace', +] as const; +/** Emoji affirmations/negations — matched by inclusion (no word boundaries). */ +const APPROVE_EMOJI = ['👍', '✅', '🚀']; +const REJECT_EMOJI = ['👎', '🛑', '❌']; + +/** + * A comment with at most this many words is read as a verdict if it contains ANY + * approve/reject phrase; a longer comment only counts when its FIRST word is a + * verdict word — so a genuine edit request ("also approve the dialog copy and …") + * isn't hijacked as approval. + */ +const MAX_VERDICT_WORDS = 6; + +/** Word/phrase boundary match: the phrase appears as whole words in ``text``. */ +function hasPhrase(text: string, phrase: string): boolean { + // Escape regex metachars (e.g. "+1", "don't"); match on non-word boundaries so + // "approve" doesn't fire on "approval" and "no" doesn't fire on "notify". + const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'i').test(text); +} + +/** + * Does ``text`` carry a substantive CHANGE instruction (beyond the leading verdict + * word)? True when it contains an imperative change verb ({@link CHANGE_VERBS}) or a + * numeric-count directive ("2 tasks", "3 sub-issues", "into 4"). Used to tell a + * bare soft negation ("no", "no thanks", "no, looks wrong") from a negation that + * asks for a re-plan ("no, make it 3 tasks", "no, just 2 tasks"). + */ +function hasChangeInstruction(text: string): boolean { + if (CHANGE_VERBS.some((v) => hasPhrase(text, v))) return true; + // Numeric-count directive: a number adjacent to a plan-unit noun, or "just N", + // "into N" — "no, just 2 tasks" / "3 sub-issues" / "into 4". + if (/\b\d+\s+(tasks?|sub-?issues?|units?|parts?|pieces?|steps?|prs?)\b/.test(text)) return true; + if (/\b(just|only|into|to)\s+\d+\b/.test(text)) return true; + return false; +} + +/** + * Classify an already-parsed comment instruction (only consulted when a pending + * plan exists on the issue). Four outcomes: + * - ``approve`` — a clear go-ahead (natural affirmations included: lgtm/yes/👍/…). + * - ``reject`` — an EXPLICIT, unambiguous discard ({@link EXPLICIT_REJECT_PHRASES} + * / 👎🛑❌). Discard is the one destructive, irreversible action, so it demands + * explicit intent — a bare "no" is NOT enough. + * - ``none`` — a change instruction → the revise loop (re-plan). Includes any + * LONGER comment (>6 words: an edit request, not a verdict — "no, go back to two + * sub-issues …") AND a SHORT soft-negation that carries a change instruction + * ("no, make it 3 tasks", which used to parse as reject and DELETE the plan). + * - ``ambiguous`` — a soft negation with NO change instruction ("no", "no thanks", + * "don't approve", "no, looks wrong"): could mean discard OR change. Never + * guess-and-destroy — the processor nudges the reviewer to pick. + * + * ``reject``/``ambiguous`` precede ``approve`` so a negation that also contains an + * affirmative word ("don't approve", "not approved") isn't read as approval. A + * reject EMOJI discards only when it LEADS the comment or appears in a short + * verdict — not merely buried in prose. An approve word paired + * with a contrastive/change qualifier ("yes, but smaller") routes to revise, not + * approve. An approve emoji (👍) is honoured regardless of length. + */ +export function parsePlanVerdict(instruction: string): PlanVerdict { + // Normalize: drop markdown emphasis/backticks, lowercase, collapse whitespace. + const text = instruction.replace(/[*_`>]/g, ' ').trim().toLowerCase().replace(/\s+/g, ' '); + if (!text) return 'none'; + + const wordCount = text.split(' ').length; + const firstWord = text.split(/[\s.,!?—–-]+/)[0]; + const short = wordCount <= MAX_VERDICT_WORDS; + + // ── DISCARD: explicit destructive intent only ──────────────────────────── + // A discard is irreversible (the plan is gone), so it requires an EXPLICIT kill + // word — never a bare soft negation. + // + // A reject EMOJI only discards when it is the VERDICT, not + // merely present. The old ``instruction.includes(❌)`` fired on a long comment + // that happened to contain ❌ anywhere ("this isn't ❌ a blocker, looks fine") + // → irreversibly deleting the plan. Require the reject emoji to lead the comment + // (first non-space char) OR appear in a SHORT (≤6-word) verdict — the same + // discipline the phrase branches already use. + const trimmed = instruction.trim(); + const rejectEmojiIsVerdict = REJECT_EMOJI.some((e) => trimmed.startsWith(e)) + || (short && REJECT_EMOJI.some((e) => instruction.includes(e))); + if (rejectEmojiIsVerdict) return 'reject'; + if (short && EXPLICIT_REJECT_PHRASES.includes(firstWord as (typeof EXPLICIT_REJECT_PHRASES)[number])) return 'reject'; + if (short && EXPLICIT_REJECT_PHRASES.some((p) => hasPhrase(text, p))) return 'reject'; + + // ── SOFT NEGATION in a SHORT comment: ambiguous, never destroy ──────────── + // A short soft negation could mean "discard" or "no, change it". If it carries a + // change instruction (a verb like "make/split" or a count like "2 tasks"), it's + // a REVISE → ``none`` (routes to the re-plan loop; this is what keeps + // "no, just 2 tasks" from falling through to discard). + // Otherwise it's genuinely ambiguous → ``ambiguous`` (the processor nudges: + // approve / reject / change). + // + // Only SHORT: a LONG comment is already substantive (an edit request) and falls + // through to ``none`` below — preserving the verified behavior that + // "no, I'd rather have three sub-issues: split the API …" REVISES (worded counts + // like "three" wouldn't match the change-instruction heuristic, so we must NOT + // route long comments through the ambiguity check or they'd wrongly nudge). + const softNegationLed = + SOFT_NEGATION_PHRASES.includes(firstWord as (typeof SOFT_NEGATION_PHRASES)[number]) + || SOFT_NEGATION_PHRASES.some((p) => hasPhrase(text, p)); + if (short && softNegationLed) { + return hasChangeInstruction(text) ? 'none' : 'ambiguous'; + } + + // ── APPROVE: clear go-ahead, short comment only ────────────────────────── + // An approve word paired with a substantive change instruction + // ("yes, but smaller", "ok but split the API layer") is NOT a clean go-ahead — + // approving would start spending against a plan the reviewer wants changed. + // Route it to the revise loop (``none``) instead. A pure approve emoji (👍) has + // no qualifier so it stays a straight approve. + if (APPROVE_EMOJI.some((e) => instruction.includes(e))) return 'approve'; + const approveLed = + (short && APPROVE_PHRASES.includes(firstWord as (typeof APPROVE_PHRASES)[number])) + || (short && APPROVE_PHRASES.some((p) => hasPhrase(text, p))); + if (approveLed) { + // A contrastive qualifier ("yes, BUT smaller"; "ok, HOWEVER split it") or an + // explicit change instruction means the reviewer is NOT cleanly approving the + // plan as-is — route to revise rather than spend on it. A bare approve + // ("yes", "lgtm", "approve") has neither → straight approve. + const hasQualifier = /(^|[^a-z0-9])(but|however|though|although|except)([^a-z0-9]|$)/i.test(text); + return (hasQualifier || hasChangeInstruction(text)) ? 'none' : 'approve'; + } + + // Anything else (incl. a long non-negation edit request) → revise loop. + return 'none'; +} diff --git a/cdk/src/handlers/shared/orchestration-dag.ts b/cdk/src/handlers/shared/orchestration-dag.ts new file mode 100644 index 000000000..7de38e202 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-dag.ts @@ -0,0 +1,192 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure dependency-graph (DAG) logic for parent/sub-issue orchestration. + * No I/O: takes a set of nodes with ``depends_on`` edges and either + * rejects the graph (cycle, dangling edge) or returns a topological + * layering the reconciler uses to release children in dependency order. + * + * Kept deliberately free of Linear/AWS types so it is trivially unit- + * testable and reusable by the auto-decomposition planner, which + * validates its own generated graph with the same cycle check before + * writing sub-issues back to the tracker. + */ + +/** A single node in the dependency graph (one Linear sub-issue). */ +export interface DagNode { + /** Stable identifier — the Linear sub-issue id (the orchestration SK). */ + readonly id: string; + /** Ids this node is blocked by; must all reach terminal-success first. */ + readonly depends_on: readonly string[]; +} + +/** Why a graph was rejected. Surfaced to the user as a terminal comment. */ +export type DagRejectionReason = 'cycle' | 'dangling_edge' | 'duplicate_id'; + +export interface DagValidationOk { + readonly ok: true; + /** + * Topological layers. ``layers[0]`` are roots (no predecessors); + * every node in ``layers[n]`` depends only on nodes in + * ``layers[<n]``. The reconciler uses layer 0 as the initial release + * set; deeper layers are released as predecessors succeed. The flat + * order (``layers.flat()``) is a valid topological sort. + */ + readonly layers: readonly (readonly string[])[]; +} + +export interface DagValidationError { + readonly ok: false; + readonly reason: DagRejectionReason; + /** + * The node ids implicated in the rejection — the cycle members, the + * nodes carrying dangling edges, or the duplicated ids. Sorted for + * stable, testable output. + */ + readonly offendingIds: readonly string[]; + /** Human-readable, user-facing explanation (used verbatim in the Linear comment). */ + readonly message: string; +} + +export type DagValidationResult = DagValidationOk | DagValidationError; + +/** + * Validate a dependency graph and, on success, return its topological + * layering. + * + * Rejects (fail-closed — a bad graph must never start any child): + * - ``duplicate_id`` — two nodes share an id (ambiguous gating). + * - ``dangling_edge`` — a ``depends_on`` points at an id not in the node set. + * - ``cycle`` — the edges form a cycle (no valid start order exists). + * + * Uses Kahn's algorithm: repeatedly peel off nodes with zero remaining + * predecessors. Each peel is one layer. If nodes remain when no node + * has zero in-degree, those nodes form (or feed) a cycle. + */ +export function validateDag(nodes: readonly DagNode[]): DagValidationResult { + // ── Duplicate ids ──────────────────────────────────────────────── + const seen = new Set<string>(); + const duplicates = new Set<string>(); + for (const n of nodes) { + if (seen.has(n.id)) duplicates.add(n.id); + seen.add(n.id); + } + if (duplicates.size > 0) { + const ids = [...duplicates].sort(); + return { + ok: false, + reason: 'duplicate_id', + offendingIds: ids, + message: + `Duplicate sub-issue id(s) in the dependency graph: ${ids.join(', ')}. ` + + 'Each sub-issue must appear once.', + }; + } + + // ── Dangling edges (depends_on → unknown id) ───────────────────── + const ids = new Set(nodes.map((n) => n.id)); + const dangling = new Set<string>(); + for (const n of nodes) { + for (const dep of n.depends_on) { + if (!ids.has(dep)) dangling.add(n.id); + } + } + if (dangling.size > 0) { + const offending = [...dangling].sort(); + return { + ok: false, + reason: 'dangling_edge', + offendingIds: offending, + message: + `Sub-issue(s) ${offending.join(', ')} depend on an issue that isn't part ` + + 'of this parent\'s sub-issue set. Blocking relations must stay within the epic.', + }; + } + + // ── Kahn's algorithm: peel zero-in-degree nodes into layers ────── + // in-degree = number of (deduplicated) predecessors still unresolved. + const remainingDeps = new Map<string, Set<string>>(); + for (const n of nodes) { + remainingDeps.set(n.id, new Set(n.depends_on)); + } + + // Reverse adjacency: dep -> nodes that depend on it (to decrement fast). + const dependents = new Map<string, string[]>(); + for (const n of nodes) { + for (const dep of new Set(n.depends_on)) { + const list = dependents.get(dep) ?? []; + list.push(n.id); + dependents.set(dep, list); + } + } + + const layers: string[][] = []; + let frontier = nodes.filter((n) => remainingDeps.get(n.id)!.size === 0).map((n) => n.id); + let resolvedCount = 0; + + while (frontier.length > 0) { + // Sort each layer for deterministic, testable output. + const layer = [...frontier].sort(); + layers.push(layer); + resolvedCount += layer.length; + + const next: string[] = []; + for (const resolvedId of layer) { + for (const dependentId of dependents.get(resolvedId) ?? []) { + const deps = remainingDeps.get(dependentId)!; + deps.delete(resolvedId); + if (deps.size === 0) next.push(dependentId); + } + } + frontier = next; + } + + if (resolvedCount < nodes.length) { + // Whatever never resolved is in (or downstream of) a cycle. + const stuck = nodes + .filter((n) => remainingDeps.get(n.id)!.size > 0) + .map((n) => n.id) + .sort(); + return { + ok: false, + reason: 'cycle', + offendingIds: stuck, + message: + 'The sub-issue blocking relations form a cycle ' + + `(involving: ${stuck.join(', ')}), so there is no valid order to start them. ` + + 'Remove the circular `blocked by` relation and re-apply the trigger.', + }; + } + + return { ok: true, layers }; +} + +/** + * Convenience: the flat topological order (roots first). Only valid to + * call on a graph ``validateDag`` accepted; throws otherwise so a caller + * can't accidentally order a rejected graph. + */ +export function topologicalOrder(nodes: readonly DagNode[]): readonly string[] { + const result = validateDag(nodes); + if (!result.ok) { + throw new Error(`Cannot order an invalid dependency graph: ${result.reason}`); + } + return result.layers.flat(); +} diff --git a/cdk/src/handlers/shared/orchestration-epic-tip.ts b/cdk/src/handlers/shared/orchestration-epic-tip.ts new file mode 100644 index 000000000..5f4528bb2 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-epic-tip.ts @@ -0,0 +1,94 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure "epic tip" selection — where a NEWLY-ADDED sub-issue with NO declared + * dependency should stack. + * + * The rule: a node added to an in-flight epic must NOT branch off bare + * ``main`` — it inherits the epic's accumulated, + * unmerged work by stacking on the epic's TIP (the most-recent leaf the rest + * of the graph already builds on). "Fall back to ``main`` only when the + * predecessor is genuinely merged (branch gone)" is handled downstream by the + * agent's runtime base-branch fetch fallback (``agent/src/repo.py`` — a base + * branch that no longer exists on origin degrades to a branch off default), + * so this layer only needs to NAME the tip; it never has to detect merge. + * + * The tip is the **leaf frontier**: nodes that nothing else depends on. Among + * those, we pick the most-recently-created real (non-integration) leaf — the + * single node a linear chain naturally extends from. This keeps the common + * "epic was a chain, add one more step" case a clean linear stack; a fan-out + * epic with multiple independent leaves yields a multi-predecessor (diamond) + * implicit dependency so the new node sees ALL of the accumulated work. + */ + +import { isIntegrationNode } from './orchestration-integration-node'; + +/** Minimal shape needed to compute the tip — a subset of OrchestrationChildRow. */ +export interface TipCandidate { + readonly sub_issue_id: string; + readonly depends_on: readonly string[]; + readonly created_at: string; +} + +/** + * Resolve the implicit predecessor set for a new unconstrained node added to + * an existing epic. Returns the sub_issue_ids the new node should stack on / + * merge in (its synthetic ``depends_on``), or ``[]`` when the epic has no + * usable tip (e.g. empty epic — degrade to root/main). + * + * Algorithm: + * 1. Consider only the EXISTING nodes (the new node isn't in the graph yet). + * 2. The leaf frontier = nodes that appear in no other node's ``depends_on``. + * 3. If an INTEGRATION node exists, it already depends on every real leaf — + * it IS the single combined tip, so stack on it alone (avoids a redundant + * diamond that re-merges what integration already merged). + * 4. Otherwise return every real leaf. One leaf → a clean linear stack; many + * leaves → a diamond so the new node inherits all parallel branches. + * + * Pure + deterministic (ties broken by sub_issue_id); no I/O. + */ +export function resolveEpicTip(existing: readonly TipCandidate[]): string[] { + if (existing.length === 0) return []; + + // A node is depended-upon if it appears in any other node's depends_on. + const dependedUpon = new Set<string>(); + for (const node of existing) { + for (const dep of node.depends_on) dependedUpon.add(dep); + } + + const leaves = existing.filter((n) => !dependedUpon.has(n.sub_issue_id)); + if (leaves.length === 0) { + // Pathological (every node depended upon ⇒ a cycle, which the DAG + // validator rejects upstream). Degrade to root rather than throw. + return []; + } + + // An integration node already merges all real leaves — it is the combined + // tip. Stack on it alone. + const integration = leaves.find((n) => isIntegrationNode(n.sub_issue_id)); + if (integration) return [integration.sub_issue_id]; + + // Real leaves only (defensive — integration handled above). One → linear + // stack; many → diamond. Sorted for deterministic depends_on ordering. + return leaves + .filter((n) => !isIntegrationNode(n.sub_issue_id)) + .map((n) => n.sub_issue_id) + .sort(); +} diff --git a/cdk/src/handlers/shared/orchestration-graph-source.ts b/cdk/src/handlers/shared/orchestration-graph-source.ts new file mode 100644 index 000000000..117ea9e8f --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -0,0 +1,102 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Trigger-agnostic orchestration graph source. + * + * The executor (validate → seed → reconcile → release → stack → + * rollup → parent lifecycle) is source-agnostic: once a DAG of + * ``{ id, depends_on, title? }`` nodes exists it doesn't care where the + * graph came from. What VARIES per trigger is only how the graph is + * *produced*. This module is that seam. + * + * "Sub-issues" is just one way to express a DAG. Three adapter tiers: + * + * 1. NATIVE graph — the tool already has the structure; the adapter + * READS it. Linear: parent → children + ``blocks`` relations + * ({@link linearGraphSource}, wrapping ``fetchSubIssueGraph``). A Jira + * adapter would map epic → stories + issue links the same way. + * + * 2. DECLARATIVE graph — the trigger has no native sub-issues, so the + * caller SUPPLIES the DAG. {@link declarativeGraphSource} takes a + * ready-made node list. This is the slot for: + * - CLI / API: a request body carrying tasks + ``depends_on`` edges. + * - Auto-decomposition: a planner agent turns ONE plain request into + * a phased DAG and hands the nodes here — reusing the ENTIRE + * executor instead of reimplementing gating/stacking/rollup. + * + * 3. DELEGATE / single — a structureless trigger (e.g. a plain Slack + * message) either stays single-task or references a native epic by id + * (tier 1). No adapter needed here. + * + * A source is a zero-arg async thunk so the caller binds whatever inputs + * it needs (token + issue id for Linear; a node list for declarative) + * before handing ``discoverOrchestration`` a uniform interface. + */ + +import { fetchSubIssueGraph, type FetchSubIssueGraphOptions, type SubIssueNode } from './linear-subissue-fetch'; + +/** + * Channel-neutral graph result. Mirrors ``FetchSubIssueGraphResult`` but + * without Linear's ``parentIssueId`` — the discovery composer already + * holds the parent id separately. + * - ``ok`` — a non-empty DAG to validate + seed. + * - ``no_children`` — no graph; caller falls through to a single task. + * - ``error`` — transient failure; caller surfaces retryable, does + * NOT silently degrade to a single task (that would drop the structure). + */ +export type OrchestrationGraphResult = + | { readonly kind: 'ok'; readonly children: readonly SubIssueNode[] } + | { readonly kind: 'no_children' } + | { readonly kind: 'error'; readonly message: string }; + +/** A bound, zero-arg producer of an orchestration DAG. */ +export type OrchestrationGraphSource = () => Promise<OrchestrationGraphResult>; + +/** + * Tier 1 — Linear native graph. Reads the parent issue's sub-issues + + * blocking relations via the existing ``fetchSubIssueGraph`` and maps the + * result to the channel-neutral shape. + */ +export function linearGraphSource( + accessToken: string, + parentIssueId: string, + fetchOptions?: FetchSubIssueGraphOptions, +): OrchestrationGraphSource { + return async () => { + const fetched = await fetchSubIssueGraph(accessToken, parentIssueId, fetchOptions); + if (fetched.kind === 'error') return { kind: 'error', message: fetched.message }; + if (fetched.kind === 'no_children') return { kind: 'no_children' }; + return { kind: 'ok', children: fetched.children }; + }; +} + +/** + * Tier 2 — declarative graph. The caller already has the node list (a + * CLI/API request, or a planner's decomposition output). An empty + * list means "no graph" → single task. Never errors (the nodes are + * in-memory); DAG validity (cycles/dangling/dupes) is still enforced + * downstream by ``validateDag`` in the discovery composer. + */ +export function declarativeGraphSource(children: readonly SubIssueNode[]): OrchestrationGraphSource { + return async () => { + if (children.length === 0) return { kind: 'no_children' }; + return { kind: 'ok', children }; + }; +} diff --git a/cdk/src/handlers/shared/orchestration-integration-node.ts b/cdk/src/handlers/shared/orchestration-integration-node.ts new file mode 100644 index 000000000..c7c90e1d4 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-integration-node.ts @@ -0,0 +1,97 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Auto-integration node for fan-out orchestrations. + * + * When a validated DAG has MORE THAN ONE leaf (a sub-issue with no + * successors), each leaf is an independent PR and nothing combines them — + * there is no single "see it all together" artifact. We append a synthetic + * integration node that depends on ALL leaves. Because it has multiple + * predecessors it is a diamond fan-in, so the existing multi-predecessor + * path (``selectBaseBranch`` → ``_merge_predecessor_branch``) merges every + * leaf branch into the integration branch with no new merge code — its PR + * is the combined result. + * + * Pure (no I/O), so the leaf computation + node construction is unit-tested + * in isolation. The discovery composer calls this AFTER ``validateDag`` + * (it needs the validated node set to compute leaves) and BEFORE + * ``seedOrchestration``, re-validating the augmented graph. + * + * Cases: + * - 0–1 leaf (linear chain, or an explicit diamond fan-in): nothing added — + * a single leaf already IS the combined result. + * - >1 leaf (pure fan-out): one synthetic node added over all leaves. + */ + +import type { SubIssueNode } from './linear-subissue-fetch'; + +/** + * Suffix marking a synthetic, platform-injected node (not a real Linear + * sub-issue). Uses ``_`` separators, NOT ``#``: the node's ``sub_issue_id`` + * flows into ``releaseChild``'s idempotency key (``${orch}_${sub}``), which + * createTaskCore validates against ``/^[a-zA-Z0-9_-]{1,128}$/`` — a ``#`` + * would 400 the child and it would never start (the same trap the meta-row + * ``#meta`` SK can use safely because it never becomes an idempotency key). + */ +export const INTEGRATION_NODE_SUFFIX = '__integration'; + +/** + * True if ``subIssueId`` is a platform-synthesized integration node rather + * than a real Linear sub-issue. Callers that would address a real Linear + * issue (reactions, MCP comments) can guard on this. + */ +export function isIntegrationNode(subIssueId: string): boolean { + return subIssueId.endsWith(INTEGRATION_NODE_SUFFIX); +} + +/** Node ids that no other node depends on — the DAG's leaves. */ +export function computeLeaves(nodes: readonly SubIssueNode[]): readonly string[] { + const hasSuccessor = new Set<string>(); + for (const n of nodes) { + for (const dep of n.depends_on) hasSuccessor.add(dep); + } + return nodes.map((n) => n.id).filter((id) => !hasSuccessor.has(id)); +} + +/** + * Given a validated DAG, return the node list to seed: unchanged when there + * is 0–1 leaf, or with a synthetic integration node appended (depending on + * all leaves) when there is more than one leaf. + * + * ``orchestrationId`` namespaces the synthetic node's id so it is unique + + * recognizable (``<orchestrationId>#integration``). The node carries no + * ``identifier`` (there is no Linear issue) and a fixed ``title`` so the + * status block / rollup render "Integration …" gracefully. + */ +export function withIntegrationNode( + nodes: readonly SubIssueNode[], + orchestrationId: string, +): { readonly nodes: readonly SubIssueNode[]; readonly added: boolean } { + const leaves = computeLeaves(nodes); + if (leaves.length <= 1) { + return { nodes, added: false }; + } + const integration: SubIssueNode = { + id: `${orchestrationId}${INTEGRATION_NODE_SUFFIX}`, + depends_on: leaves, + title: 'Integration — combine sub-issue results', + }; + return { nodes: [...nodes, integration], added: true }; +} diff --git a/cdk/src/handlers/shared/orchestration-log-events.ts b/cdk/src/handlers/shared/orchestration-log-events.ts new file mode 100644 index 000000000..a17746d69 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-log-events.ts @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Stable, machine-greppable log-event names for sub-issue orchestration. + * Emitted as the ``event`` field on structured logs. + * + * WHY A CENTRAL MODULE: these strings are a TEST CONTRACT. End-to-end and + * automated dev tests assert on orchestration behavior by grepping + * CloudWatch for these exact event names (the orchestration plane is + * event-driven and has no synchronous API to assert against). Defining + * them in one place means: + * - a test references ``ORCH_LOG.childReleased``, not a copy-pasted + * string that silently drifts when a log line is reworded; + * - renaming an event is a single edit that the type system propagates; + * - this file IS the catalogue of "what to look for in the logs", + * which is exactly the long-term automated-testing question. + * + * Convention: ``orch.<phase>.<outcome>`` so a test can match a whole + * phase with a prefix (``orch.reconcile.*``) or an exact transition. + * Every emit site should also include the structured fields listed in the + * doc comment so log-based assertions can bind to ids, not just names. + */ +export const ORCH_LOG = { + // ── Discovery (webhook → seed) ────────────────────────────────── + /** A labeled parent had a valid sub-issue graph; rows seeded. + * Fields: orchestration_id, parent_linear_issue_id, child_count, root_count. */ + discoverySeeded: 'orch.discovery.seeded', + /** Parent had no sub-issues → fell back to a single task. + * Fields: parent_linear_issue_id. */ + discoverySingleTask: 'orch.discovery.single_task', + /** Graph rejected (cycle / dangling / dup) — no rows, terminal comment. + * Fields: parent_linear_issue_id, reason, offending_ids. */ + discoveryRejected: 'orch.discovery.rejected', + /** Transient Linear error reading sub-issues — terminal comment, no seed. + * Fields: parent_linear_issue_id, message. */ + discoveryError: 'orch.discovery.error', + + // ── Release (root + reconciler) ───────────────────────────────── + /** A child task was created (released). Fields: orchestration_id, + * sub_issue_id, child_task_id, base_branch, merge_branch_count, source + * ('root' | 'reconciler' | 'sweep'). */ + childReleased: 'orch.child.released', + /** A release attempt's createTaskCore returned non-success. Fields: + * orchestration_id, sub_issue_id, status, response_body. */ + childReleaseFailed: 'orch.child.release_failed', + + // ── Reconcile (TaskTable stream → gating) ─────────────────────── + /** A child reached terminal-success; gating re-evaluated. Fields: + * orchestration_id, sub_issue_id, released_count. */ + reconcileSuccess: 'orch.reconcile.success', + /** A child failed/cancelled/timed-out or built-broken; dependents + * skipped. Fields: orchestration_id, sub_issue_id, skipped_ids. */ + reconcileFailurePropagated: 'orch.reconcile.failure_propagated', + + // ── Rollup (parent comment via this plane) ────────────────────── + /** A parent rollup comment was posted. Fields: orchestration_id, + * parent_linear_issue_id, rollup_kind ('progress' | 'complete' | + * 'partial_failure' | 'cancelled'). */ + rollupPosted: 'orch.rollup.posted', + /** Posting the parent rollup comment failed (best-effort). Fields: + * orchestration_id, parent_linear_issue_id, rollup_kind. */ + rollupFailed: 'orch.rollup.failed', + + // ── Completion / cancel ───────────────────────────────────────── + /** Every child reached a terminal orchestration state. Fields: + * orchestration_id, parent_linear_issue_id, succeeded, failed, skipped. */ + orchestrationComplete: 'orch.complete', + /** Parent cancel cascaded to non-terminal children. Fields: + * orchestration_id, parent_linear_issue_id, cancelled_count. */ + cancelCascaded: 'orch.cancel.cascaded', + + // ── Backstop (scheduled sweep) ────────────────────────────────── + /** The sweep recovered a child the live reconciler missed. Fields: + * orchestration_id, sub_issue_id, recovery ('lost_release' | + * 'lost_terminal'). */ + sweepRecovered: 'orch.sweep.recovered', +} as const; + +export type OrchLogEvent = (typeof ORCH_LOG)[keyof typeof ORCH_LOG]; diff --git a/cdk/src/handlers/shared/orchestration-store.ts b/cdk/src/handlers/shared/orchestration-store.ts new file mode 100644 index 000000000..fcae203a0 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -0,0 +1,954 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Persistence for the orchestration DAG. + * Writes one row per sub-issue to ``OrchestrationTable`` (PK + * ``orchestration_id``, SK ``sub_issue_id``) after the graph has been + * fetched (``linear-subissue-fetch``) and validated + * (``orchestration-dag``). + * + * Idempotent on webhook replay: the ``orchestration_id`` is *derived + * deterministically* from the parent Linear issue id (not random), and + * rows are written with a ``attribute_not_exists`` condition on first + * write. A replay of the same parent trigger therefore re-derives the + * same id and the conditional writes no-op instead of duplicating + * children. The reconciler owns child-status transitions; this module + * only seeds the initial ``blocked`` / ``ready`` rows. + */ + +import * as crypto from 'crypto'; +import { + type DynamoDBDocumentClient, + BatchWriteCommand, + GetCommand, + QueryCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import type { SubIssueNode } from './linear-subissue-fetch'; +import { logger } from './logger'; +import { validateDag } from './orchestration-dag'; +import { resolveEpicTip } from './orchestration-epic-tip'; +import type { AttachmentRecord } from './types'; + +/** Orchestration-local lifecycle marker on each sub-issue row. */ +export type ChildStatus = + | 'ready' // no predecessors / all predecessors succeeded — releasable + | 'blocked' // waiting on predecessors + | 'releasing' // TRANSIENT (flip status first, then create): claimed for release — + // the winner is between the atomic blocked|ready→releasing claim and the + // createTaskCore that follows. Never terminal; a crash mid-release leaves the + // row here and the sweep/rollback recovers it back to ready. + | 'released' // child task created + | 'succeeded' + | 'failed' + | 'skipped'; // a predecessor failed; this child will never start + +/** One persisted sub-issue row. */ +export interface OrchestrationChildRow { + readonly orchestration_id: string; + readonly sub_issue_id: string; + /** The epic this child belongs to, as the surface identifies it. */ + readonly parent_issue_ref: string; + /** Opaque credentials handle for the surface (a workspace/tenant id that keys + * a token registry). The engine never interprets it. */ + readonly credentials_ref: string; + readonly repo: string; + readonly depends_on: readonly string[]; + readonly child_status: ChildStatus; + /** + * The ABCA ``task_id`` created for this child once released. Stamped by + * ``releaseChild`` alongside the ``child_status → released`` flip; + * absent until the child is released. The ``ChildTaskIndex`` GSI is + * keyed on this so the reconciler resolves a terminal task back to its + * orchestration row. + */ + readonly child_task_id?: string; + /** + * The released child task's head branch. Persisted on the + * release flip so a DEPENDENT child can stack on / merge it. Absent + * until released. + */ + readonly child_branch_name?: string; + /** Human-facing id for display, when the surface has one (e.g. ``ENG-42``). */ + readonly display_id?: string; + /** Sub-issue title, used to build the child task description. */ + readonly title?: string; + /** + * Sub-issue scope/description. When the graph came from the planner + * decomposing a plain issue, this is its rich per-piece scope — persisted at + * seed so the coding agent's task_description carries what the reviewer + * approved (e.g. a promised filename), not the title alone. Absent when the + * graph was read from sub-issues a human already wrote, since that path + * fetches titles only. + */ + readonly description?: string; + /** + * This sub-issue's OWN screened attachments (distinct from the parent epic's, + * which ride on the meta row's release_context). A human-authored + * sub-issue can carry a file attached to IT specifically (a mockup for just + * that piece); hydrated + stored at seed and merged with the inherited parent + * records when the child is released. JSON-encoded `passed` AttachmentRecords; + * absent when the sub-issue has no own attachments (the common case). + */ + readonly pre_screened_attachments?: readonly AttachmentRecord[]; + /** + * A terminal failure reason recorded when the + * child could NOT be turned into a task at all (a deterministic create + * failure — guardrail/content-policy block, validation error) so it never + * got a ``child_task_id``. The panel's failure-reason resolver falls back to + * this string when there's no task to read an ``error_message`` from, so the + * child's ❌ carries a "why + how to fix" line instead of a bare ❌. Absent + * for the normal case (a released child's reason comes from its task record). + */ + readonly failure_reason?: string; + readonly created_at: string; + readonly updated_at: string; + /** TTL epoch (seconds) for eventual cleanup. */ + readonly ttl?: number; +} + +/** + * Marshal one persisted item into a child row, reading the renamed attributes + * through {@link readRenamed} so a row written before the rename still loads. + * + * This is deliberately EXPLICIT rather than a cast. The previous + * ``item as unknown as OrchestrationChildRow`` made the compiler report success + * while the underlying attribute names were whatever the writer happened to use — + * so renaming a field on the interface would have silently yielded ``undefined`` + * for every existing row, with nothing in the type system or a new-shaped test + * fixture to catch it. + * + * Attributes that were never renamed are passed through as-is. + */ +function toChildRow(item: Record<string, unknown>): OrchestrationChildRow { + const parentIssueId = readRenamed(item, 'parent_issue_ref', 'parent_linear_issue_id') ?? ''; + const credentialsRef = readRenamed(item, 'credentials_ref', 'linear_workspace_id') ?? ''; + const displayId = readRenamed(item, 'display_id', 'linear_identifier'); + return { + ...(item as unknown as OrchestrationChildRow), + parent_issue_ref: parentIssueId, + credentials_ref: credentialsRef, + ...(displayId !== undefined && { display_id: displayId }), + }; +} + +/** + * Release context persisted on the parent-meta row so the reconciler can + * release downstream children WITHOUT re-resolving auth (the webhook + * already resolved the platform user + Linear OAuth at seed time). The + * reconciler runs off the TaskTable stream and has no Linear webhook + * payload to re-derive these from. + */ +export interface OrchestrationReleaseContext { + /** Platform user the children are attributed to (parent's submitter). */ + readonly platform_user_id: string; + /** + * The trigger channel that seeded this orchestration. Threaded onto child + * tasks (createTaskCore channelSource) and used by the reconciler to + * dispatch the parent rollup to the right plane. Defaults to ``'linear'`` + * when absent (back-compat: orchestrations seeded before this field + * existed, and the only wired trigger today). This is the trigger-agnostic + * seam: a future GitHub/Slack/Jira trigger seeds with its own source and the + * release + rollup paths follow it without code changes here. + */ + readonly channel_source?: string; + /** Linear OAuth secret ARN for the agent's outbound Linear GraphQL + * (reactions/state via linear_reactions.py — there is no Linear MCP). */ + readonly linear_oauth_secret_arn?: string; + readonly linear_workspace_slug?: string; + readonly linear_project_id?: string; + /** + * Parent-issue attachments, screened + stored ONCE at seed time, so every + * child inherits them: the parent's attached spec must reach the agents that + * write the code, not only the planner that never sees the repo. These are `passed` + * AttachmentRecords (S3 keys, not bytes); each released child references the + * same S3 objects read-only via createTaskCore's preScreenedAttachments seam. + * The PARENT owns the objects' lifecycle — children never delete them. + */ + readonly pre_screened_attachments?: readonly AttachmentRecord[]; +} + +export interface SeedOrchestrationParams { + readonly ddb: DynamoDBDocumentClient; + readonly tableName: string; + readonly parentIssueRef: string; + readonly credentialsRef: string; + readonly repo: string; + readonly children: readonly SubIssueNode[]; + /** ISO timestamp for created_at/updated_at (injected for testability). */ + readonly now: string; + /** Optional TTL epoch seconds. */ + readonly ttl?: number; + /** Release context stamped on the meta row for the reconciler. */ + readonly releaseContext: OrchestrationReleaseContext; +} + +export interface SeedOrchestrationResult { + readonly orchestrationId: string; + readonly rowsWritten: number; + /** True when an existing orchestration was found (replay) — no new rows. */ + readonly alreadyExisted: boolean; +} + +/** Hex chars of the sha256 kept for the orchestration id (128 bits — ample to + * avoid accidental collisions between distinct parent refs). */ +const ORCH_ID_HASH_HEX_LENGTH = 32; + +/** + * Deterministically derive the ``orchestration_id`` from the parent issue ref. + * Same parent → same id, which is what makes webhook replay idempotent. Prefixed + * + hashed so the id is opaque and fixed-length regardless of the ref format. + * + * NOT tenant-scoped: the ref alone is hashed, with no credentials/workspace + * component. That is safe for the refs in use today (Linear issue ids are + * workspace-unique UUIDs, so two tenants cannot produce the same one), but it is + * a property of those refs rather than of this function — a surface keying on a + * per-project ref like ``PROJ-42`` would have two tenants land on one id. + * + * Deliberately left as-is rather than mixing the tenant in: this id is a + * persisted partition key, so changing the derivation would orphan every + * in-flight epic's rows (the reconciler would re-derive a different id and find + * nothing, stranding the epic silently — the exact failure mode this codebase + * has been fixing). Instead the seed path REFUSES a collision it detects, so a + * surface with non-unique refs fails loudly at onboarding rather than letting two + * tenants share one graph. See {@link seedOrchestration}. + */ +export function deriveOrchestrationId(parentIssueRef: string): string { + const hash = crypto.createHash('sha256').update(parentIssueRef).digest('hex').slice(0, ORCH_ID_HASH_HEX_LENGTH); + return `orch_${hash}`; +} + +/** + * Read a persisted attribute that has been RENAMED, tolerating both spellings. + * + * Rows already in the table carry the old attribute name, and they outlive the + * deploy that renames it — an epic mid-flight when the rename ships must still + * settle. So every read prefers the new name and falls back to the legacy one, + * and writes emit BOTH for a transition period (see ``dualWrite``) so a rollback + * to the previous code also keeps working. + * + * Returns undefined when neither is present, so a caller can distinguish + * "absent" from "empty string". + */ +function readRenamed(item: Record<string, unknown>, current: string, legacy: string): string | undefined { + const value = item[current] ?? item[legacy]; + return value === undefined || value === null ? undefined : String(value); +} + +/** + * Emit a renamed attribute under BOTH names on write. Costs a duplicated small + * string per row and buys a reversible deploy: code running either spelling can + * read a row written by the other. Drop the legacy half only once no deployed + * code reads it and no in-flight rows predate the rename. + */ +function dualWrite<C extends string, L extends string>( + current: C, + legacy: L, + value: string, +): Record<C | L, string> { + return { [current]: value, [legacy]: value } as Record<C | L, string>; +} + +/** + * Parse the meta row's ``pre_screened_attachments_json`` back into records. + * Best-effort: a malformed/absent value yields ``[]`` (children just run without + * the parent attachments rather than the whole epic failing to release). + */ +function parsePreScreenedAttachments(raw: unknown, orchestrationId: string): AttachmentRecord[] { + if (typeof raw !== 'string' || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as AttachmentRecord[]) : []; + } catch (err) { + logger.warn('Orchestration meta pre_screened_attachments_json unparseable — releasing children without them', { + orchestration_id: orchestrationId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +/** DynamoDB BatchWriteItem hard limit: at most 25 put/delete requests per call. */ +const DDB_BATCH_WRITE_MAX_ITEMS = 25; + +/** Marker SK for the parent-meta row (sorts before any UUID sub_issue_id). */ +const PARENT_META_SK = '#meta'; + +/** + * An existing orchestration's id is held by a DIFFERENT parent issue or tenant. + * + * Its own class so the caller can tell it from a transient persistence failure: + * a write that failed is worth retrying, whereas a collision will recur on every + * attempt and needs the ref scheme changed, so telling the user to re-trigger + * would send them round a loop that cannot succeed. + */ +export class OrchestrationIdCollisionError extends Error { + constructor(public readonly orchestrationId: string) { + super(`Orchestration id ${orchestrationId} is already held by a different parent issue or tenant`); + this.name = 'OrchestrationIdCollisionError'; + } +} + +/** + * Who an existing orchestration belongs to, read from its meta row under either + * the neutral or the legacy attribute names. Used to tell a genuine replay from + * an id collision. + * + * A row missing these (written by code that predates them) reads as undefined, + * which deliberately does NOT trip the collision check — refusing to reconcile a + * legacy in-flight epic would be a worse failure than the collision this guards + * against, which cannot occur for the refs in use today. + */ +function toMetaOwner(item: Record<string, unknown>): { + parentIssueRef: string | undefined; + credentialsRef: string | undefined; +} { + return { + parentIssueRef: readRenamed(item, 'parent_issue_ref', 'parent_linear_issue_id'), + credentialsRef: readRenamed(item, 'credentials_ref', 'linear_workspace_id'), + }; +} + +/** + * Seed ``OrchestrationTable`` with one row per sub-issue plus a parent + * meta row. Idempotent: if the parent meta row already exists (replay), + * returns ``alreadyExisted: true`` and writes nothing. + * + * Initial ``child_status``: ``ready`` when ``depends_on`` is empty + * (a root — the reconciler releases these immediately), else + * ``blocked``. + * + * Refuses to adopt an existing orchestration that belongs to a DIFFERENT tenant + * (see {@link deriveOrchestrationId} for why the id itself isn't tenant-scoped). + */ +export async function seedOrchestration( + params: SeedOrchestrationParams, +): Promise<SeedOrchestrationResult> { + const { ddb, tableName, parentIssueRef, credentialsRef, repo, children, now, ttl, releaseContext } = params; + const orchestrationId = deriveOrchestrationId(parentIssueRef); + + // Idempotency gate: a prior run for this parent already seeded rows. + const existing = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + })); + if (existing.Item) { + // The id is derived from the parent ref alone, so two tenants whose refs + // collide would derive the SAME id — and this replay gate would hand the + // second one the first one's graph: their epic would show the other tenant's + // children, and releases would run against the other tenant's repo under the + // other tenant's credentials. Cannot happen with the workspace-unique UUIDs + // in use today, but it must not degrade quietly if a surface with per-project + // refs is onboarded, so compare the identity the meta row already records. + const owner = toMetaOwner(existing.Item); + // Only a row that RECORDS a conflicting owner is a collision. An absent value + // (a row written before these attributes existed) is unknown, not different — + // treating it as a collision would refuse to reconcile a legacy in-flight + // epic, a worse and far likelier failure than the one being guarded against. + const collides = (owner.credentialsRef !== undefined && owner.credentialsRef !== credentialsRef) + || (owner.parentIssueRef !== undefined && owner.parentIssueRef !== parentIssueRef); + if (collides) { + logger.error('Orchestration id collision across tenants — refusing to seed', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + existing_parent_issue_ref: owner.parentIssueRef, + // Ids, never credentials themselves. + credentials_ref: credentialsRef, + existing_credentials_ref: owner.credentialsRef, + }); + throw new OrchestrationIdCollisionError(orchestrationId); + } + logger.info('Orchestration already seeded — skipping (idempotent replay)', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + }); + return { orchestrationId, rowsWritten: 0, alreadyExisted: true }; + } + + const childRows: OrchestrationChildRow[] = children.map((c) => ({ + orchestration_id: orchestrationId, + sub_issue_id: c.id, + // Renamed attributes are written under both spellings — see dualWrite. + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + depends_on: c.depends_on, + child_status: c.depends_on.length === 0 ? 'ready' : 'blocked', + ...(c.identifier !== undefined && dualWrite('display_id', 'linear_identifier', c.identifier)), + ...(c.title !== undefined && { title: c.title }), + ...(c.description !== undefined && c.description !== '' && { description: c.description }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + })); + + const metaRow = { + orchestration_id: orchestrationId, + sub_issue_id: PARENT_META_SK, + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + child_count: children.length, + // Release context for the reconciler (downstream releases run off the + // TaskTable stream with no Linear webhook payload to re-derive these). + platform_user_id: releaseContext.platform_user_id, + ...(releaseContext.channel_source !== undefined && { + channel_source: releaseContext.channel_source, + }), + ...(releaseContext.linear_oauth_secret_arn !== undefined && { + linear_oauth_secret_arn: releaseContext.linear_oauth_secret_arn, + }), + ...(releaseContext.linear_workspace_slug !== undefined && { + linear_workspace_slug: releaseContext.linear_workspace_slug, + }), + ...(releaseContext.linear_project_id !== undefined && { + linear_project_id: releaseContext.linear_project_id, + }), + // Parent attachments, inherited by every child — stored as a JSON string so the nested + // AttachmentRecord[] round-trips cleanly through the Document client without + // per-field marshalling. Omitted when the parent had none. + ...(releaseContext.pre_screened_attachments && releaseContext.pre_screened_attachments.length > 0 && { + pre_screened_attachments_json: JSON.stringify(releaseContext.pre_screened_attachments), + }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + }; + + // BatchWrite in chunks of 25 (DDB limit). The meta row goes last so a + // partial failure can't leave a meta row claiming a fully-seeded + // orchestration when child rows are missing — a replay re-derives the + // same id, sees no meta row, and re-seeds. + const allRows: Array<Record<string, unknown>> = [ + ...childRows.map((r) => ({ ...r })), + { ...metaRow }, + ]; + let rowsWritten = 0; + for (let i = 0; i < allRows.length; i += DDB_BATCH_WRITE_MAX_ITEMS) { + const chunk = allRows.slice(i, i + DDB_BATCH_WRITE_MAX_ITEMS); + await ddb.send(new BatchWriteCommand({ + RequestItems: { + [tableName]: chunk.map((Item) => ({ PutRequest: { Item } })), + }, + })); + rowsWritten += chunk.length; + } + + logger.info('Orchestration seeded', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + child_count: children.length, + rows_written: rowsWritten, + }); + + return { orchestrationId, rowsWritten, alreadyExisted: false }; +} + +/** Result of extending an already-seeded orchestration with newly-added sub-issues. */ +export interface ExtendOrchestrationResult { + readonly orchestrationId: string; + /** Sub-issue ids newly ADDED to the DAG by this extend (empty if nothing new). */ + readonly addedSubIssueIds: readonly string[]; + /** + * Subset of ``addedSubIssueIds`` that are immediately releasable — their + * predecessors are all already ``succeeded`` (or they're new roots). The + * caller releases these now; the rest are ``blocked`` and the reconciler + * releases them as predecessors finish, exactly like seed-time children. + */ + readonly releasableSubIssueIds: readonly string[]; + /** Why an extend was rejected (cycle introduced by the new edges), if any. */ + readonly rejected?: { readonly reason: string; readonly message: string }; +} + +/** + * Extend an ALREADY-SEEDED orchestration with sub-issues added to the Linear + * epic after the first seed. The seed path is + * idempotent (frozen at first seed) so a graph can't grow on its own; this is + * the additive counterpart, invoked when a labeled parent that already has an + * orchestration is re-triggered. + * + * Diffs the freshly-fetched ``graph`` against the persisted children: + * - existing nodes are LEFT UNTOUCHED (their status/branch/task are preserved + * — we never re-seed or reset a node that already ran), + * - genuinely-new nodes are validated (the augmented graph must stay acyclic), + * then added as ``ready`` (deps all already succeeded, or no deps) or + * ``blocked``, + * - the meta ``child_count`` is bumped. + * + * Idempotent: re-running with no new nodes is a no-op (empty result). A cycle + * introduced by the new edges rejects WITHOUT writing anything. + * + * @param graph the full current sub-issue node set, already augmented with the + * synthetic integration node if the graph fans out, from the same source the + * seed used. + */ +export async function extendOrchestration(params: { + readonly ddb: DynamoDBDocumentClient; + readonly tableName: string; + readonly parentIssueRef: string; + readonly credentialsRef: string; + readonly repo: string; + readonly graph: readonly SubIssueNode[]; + readonly now: string; + readonly ttl?: number; +}): Promise<ExtendOrchestrationResult> { + const { ddb, tableName, parentIssueRef, credentialsRef, repo, graph, now, ttl } = params; + const orchestrationId = deriveOrchestrationId(parentIssueRef); + + const snapshot = await loadOrchestration(ddb, tableName, orchestrationId); + if (!snapshot) { + // No existing orchestration — caller should have seeded, not extended. + return { orchestrationId, addedSubIssueIds: [], releasableSubIssueIds: [] }; + } + + const existingIds = new Set(snapshot.children.map((c) => c.sub_issue_id)); + const newNodes = graph.filter((n) => !existingIds.has(n.id)); + if (newNodes.length === 0) { + return { orchestrationId, addedSubIssueIds: [], releasableSubIssueIds: [] }; + } + + // Validate the AUGMENTED graph (existing + new) — adding nodes/edges must not + // introduce a cycle or a dangling edge. Reject without writing if it does. + const validation = validateDag(graph.map((n) => ({ id: n.id, depends_on: n.depends_on }))); + if (!validation.ok) { + logger.warn('Orchestration extend rejected — augmented graph invalid', { + orchestration_id: orchestrationId, reason: validation.reason, + }); + return { + orchestrationId, + addedSubIssueIds: [], + releasableSubIssueIds: [], + rejected: { reason: validation.reason, message: validation.message }, + }; + } + + // A new node with NO declared dependency must NOT branch off bare + // main — it inherits the epic's accumulated unmerged work by stacking on the + // epic TIP (the existing leaf frontier). We inject that as a synthetic + // ``depends_on`` so the existing dependency gating + base-branch stacking treat + // it like any other dependent; "fall back to main only when merged" is handled + // downstream by the agent's base-fetch fallback. Nodes that DECLARED a + // dependency keep their explicit edges (user intent wins over the tip). + const epicTip = resolveEpicTip(snapshot.children); + const withImplicitDeps = newNodes.map((n) => ({ + node: n, + // Only unconstrained new nodes inherit the tip; and never self-depend + // (the tip is computed from EXISTING nodes, so a new id can't appear). + depends_on: n.depends_on.length > 0 ? n.depends_on : epicTip, + })); + + // A node is immediately releasable iff every predecessor is already + // ``succeeded`` (or it has none). Predecessors may be existing (check their + // persisted status) or other new nodes (not succeeded yet → blocked). + const succeeded = new Set( + snapshot.children.filter((c) => c.child_status === 'succeeded').map((c) => c.sub_issue_id), + ); + const releasable = new Set<string>(); + const newRows: OrchestrationChildRow[] = withImplicitDeps.map(({ node: n, depends_on }) => { + const allDepsSucceeded = depends_on.every((d) => succeeded.has(d)); + if (allDepsSucceeded) releasable.add(n.id); + return { + orchestration_id: orchestrationId, + sub_issue_id: n.id, + ...dualWrite('parent_issue_ref', 'parent_linear_issue_id', parentIssueRef), + ...dualWrite('credentials_ref', 'linear_workspace_id', credentialsRef), + repo, + depends_on, + child_status: allDepsSucceeded ? 'ready' : 'blocked', + ...(n.identifier !== undefined && dualWrite('display_id', 'linear_identifier', n.identifier)), + ...(n.title !== undefined && { title: n.title }), + ...(n.description !== undefined && n.description !== '' && { description: n.description }), + created_at: now, + updated_at: now, + ...(ttl !== undefined && { ttl }), + }; + }); + + // Persist new child rows (chunks of 25), then bump meta child_count. + for (let i = 0; i < newRows.length; i += DDB_BATCH_WRITE_MAX_ITEMS) { + const chunk = newRows.slice(i, i + DDB_BATCH_WRITE_MAX_ITEMS); + await ddb.send(new BatchWriteCommand({ + RequestItems: { [tableName]: chunk.map((Item) => ({ PutRequest: { Item } })) }, + })); + } + // Bump child_count AND clear rollup_posted_at: if this epic had ALREADY + // reached all-terminal and posted its rollup, adding a node re-opens it. + // Clearing the claim lets the reconciler re-settle the parent state to + // complete (re-claim) once the new node finishes — without this, a + // post-completion addition would leave the epic stuck "in progress" forever. + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET child_count = :n, updated_at = :now REMOVE rollup_posted_at', + ExpressionAttributeValues: { ':n': snapshot.children.length + newRows.length, ':now': now }, + })); + + logger.info('Orchestration extended', { + orchestration_id: orchestrationId, + parent_issue_ref: parentIssueRef, + added: newRows.length, + releasable: releasable.size, + added_ids: newRows.map((r) => r.sub_issue_id), + }); + + return { + orchestrationId, + addedSubIssueIds: newRows.map((r) => r.sub_issue_id), + releasableSubIssueIds: [...releasable], + }; +} + +/** + * Claim the right to post the parent rollup comment exactly once. + * The orchestration can reach "all children terminal" on more than + * one TaskTable-stream event (the last child's record often gets two + * MODIFYs — e.g. status→COMPLETED then pr_url/build_passed written — both + * observing all-terminal), which without a guard posts the rollup twice. + * + * Conditionally stamps ``rollup_posted_at`` on the parent-meta row. The + * first caller wins (returns true → post the comment); a racing/repeat + * caller loses the conditional write (returns false → skip). Mirrors the + * release-flip idempotency pattern. + */ +export async function claimRollup( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + now: string, +): Promise<boolean> { + try { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET rollup_posted_at = :now', + ConditionExpression: 'attribute_not_exists(rollup_posted_at)', + ExpressionAttributeValues: { ':now': now }, + })); + return true; + } catch (err) { + if ((err as { name?: string })?.name === 'ConditionalCheckFailedException') return false; + throw err; + } +} + +/** + * Release the once-only rollup claim so a RE-COMPLETING epic can re-settle its + * parent state. When an already-completed epic re-opens + * (a cascade/iteration revives it), the ``rollup_posted_at`` stamp from the + * FIRST completion would otherwise make {@link claimRollup} fail forever — so + * the panel body re-settles to ✅ but the parent reaction/state never re-mirror + * (stuck on 👀/In Progress). ``extendOrchestration`` already clears it on the + * extend path; the cascade re-open path must too. Best-effort; unconditional + * REMOVE (idempotent — a no-op when already absent). + */ +export async function clearRollupClaim( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + now: string, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET updated_at = :now REMOVE rollup_posted_at', + ExpressionAttributeValues: { ':now': now }, + })); +} + +/** + * Persist a sub-issue's OWN screened attachments on its child row: a + * human-authored sub-issue can carry a file attached to IT, distinct from + * the parent epic's. Written as a native list (createTaskCore's records are + * plain JSON objects; DynamoDB stores nested lists/maps directly, so + * ``loadOrchestration``'s row cast reads them back as ``AttachmentRecord[]`` with + * no bespoke parse). Conditional on the child row existing so a racing TTL reap + * can't resurrect it. Best-effort at the call site — a child's own attachment is + * enrichment on top of the load-bearing inherited parent spec. + */ +export async function setChildOwnAttachments( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + subIssueId: string, + records: readonly AttachmentRecord[], + now: string, +): Promise<void> { + if (records.length === 0) return; + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: subIssueId }, + UpdateExpression: 'SET pre_screened_attachments = :recs, updated_at = :now', + ConditionExpression: 'attribute_exists(sub_issue_id)', + ExpressionAttributeValues: { ':recs': records, ':now': now }, + })); +} + +/** + * Claim the one-time "I responded to this comment" marker so a webhook + * REDELIVERY doesn't re-post. Linear redelivers + * a comment webhook when the handler exceeds its ~5s ack window; without a + * claim, the parent-epic disambiguation reply re-posted on every redelivery + * (50+ duplicates). Keyed on the orchestration + the triggering comment id, so + * the FIRST delivery wins and every redelivery is a no-op. The marker carries a + * TTL (the table's ``ttl`` attribute) so these rows self-expire — they're only + * needed for the redelivery window. Returns true only for the first caller. + * + * @param ttlEpochSeconds absolute epoch-seconds expiry for the marker row. + */ +export async function claimCommentAck( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string, + now: string, + ttlEpochSeconds: number, +): Promise<boolean> { + try { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: `ack#${commentId}` }, + // attribute_not_exists on the PK is the standard "create-once" guard — + // a replay finds the row present and the condition fails. ``ttl`` is a + // DynamoDB reserved keyword → must be aliased via ExpressionAttributeNames. + UpdateExpression: 'SET acked_at = :now, #ttl = :ttl', + ConditionExpression: 'attribute_not_exists(orchestration_id)', + ExpressionAttributeNames: { '#ttl': 'ttl' }, + ExpressionAttributeValues: { ':now': now, ':ttl': ttlEpochSeconds }, + })); + return true; + } catch (err) { + if ((err as { name?: string })?.name === 'ConditionalCheckFailedException') return false; + throw err; + } +} + +/** Sort-key of the parent-meta row. Exported so the reconciler can + * separate it from child rows after a Query. */ +export const ORCHESTRATION_META_SK = PARENT_META_SK; + +/** Parsed parent-meta row, including the reconciler's release context. */ +export interface OrchestrationMeta { + readonly orchestration_id: string; + readonly parent_issue_ref: string; + readonly credentials_ref: string; + readonly repo: string; + readonly child_count: number; + readonly release_context: OrchestrationReleaseContext; + /** + * Linear comment id of the live status block, stamped at seed. + * The reconciler edits this comment in place on each child transition and + * one last time with the final rollup. Absent if the seed-time create + * failed (best-effort) — the reconciler then falls back to a fresh + * comment for the final rollup. + */ + readonly status_comment_id?: string; + /** + * The ``@bgagent retry`` comment that re-ran this epic, if one did. + * + * Recorded so the epic's next settle can move that comment's marker off the + * receipt 👀 to the actual outcome. The retry path acks the comment and then + * hands off to the reconciler, which is driven by task events and so has no + * other way to learn which comment asked for the run — which left the one view + * the user is watching stuck on "looking at it" indefinitely. Cleared once + * settled, so a later settle can't re-swap a marker on an old comment. + */ + readonly retry_comment_id?: string; +} + +/** + * Stamp the live status-block comment id on the parent-meta row. + * Called once at seed after the comment is created. Best-effort; a failure + * just means the reconciler can't edit-in-place and posts a fresh final + * rollup instead. Not conditional — the single seed path is the only writer. + */ +export async function setStatusCommentId( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + UpdateExpression: 'SET status_comment_id = :cid', + ExpressionAttributeValues: { ':cid': commentId }, + })); +} + +/** + * Record (or clear) the ``@bgagent retry`` comment awaiting an outcome marker. + * + * Pass a comment id when a retry starts; pass undefined once its marker has been + * settled, so the next settle of the same epic doesn't re-swap a stale comment. + * Best-effort like the panel-id write: failing to record it costs a marker + * update, never the retry itself. + */ +export async function setRetryCommentId( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, + commentId: string | undefined, +): Promise<void> { + await ddb.send(new UpdateCommand({ + TableName: tableName, + Key: { orchestration_id: orchestrationId, sub_issue_id: PARENT_META_SK }, + ...(commentId === undefined + ? { UpdateExpression: 'REMOVE retry_comment_id' } + : { + UpdateExpression: 'SET retry_comment_id = :cid', + ExpressionAttributeValues: { ':cid': commentId }, + }), + })); +} + +/** All rows for one orchestration: the meta row + every child row. */ +export interface OrchestrationSnapshot { + readonly meta: OrchestrationMeta; + readonly children: readonly OrchestrationChildRow[]; +} + +/** + * Load every row for an orchestration (meta + children). Returns null when the + * orchestration id has no rows (e.g. TTL-reaped). The reconciler calls this after + * resolving a terminal child's orchestration via the ChildTaskIndex GSI. + * + * Paginates: a DynamoDB Query returns at most one 1MB page, so a large epic + * (many children + accumulated ``ack#`` marker rows) would otherwise silently + * truncate — dropping children from the completion check / panel and stranding + * the epic. Follows ``LastEvaluatedKey`` to read all rows (mirrors + * ``findOrchestrationIds``'s Scan pagination). + */ +export async function loadOrchestration( + ddb: DynamoDBDocumentClient, + tableName: string, + orchestrationId: string, +): Promise<OrchestrationSnapshot | null> { + const items: Array<Record<string, unknown>> = []; + let exclusiveStartKey: Record<string, unknown> | undefined; + do { + const res: import('@aws-sdk/lib-dynamodb').QueryCommandOutput = await ddb.send(new QueryCommand({ + TableName: tableName, + KeyConditionExpression: 'orchestration_id = :oid', + ExpressionAttributeValues: { ':oid': orchestrationId }, + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + })); + items.push(...((res.Items ?? []) as Array<Record<string, unknown>>)); + exclusiveStartKey = res.LastEvaluatedKey; + } while (exclusiveStartKey); + if (items.length === 0) return null; + + const metaItem = items.find((i) => i.sub_issue_id === PARENT_META_SK); + if (!metaItem) { + // A non-epic issue accumulates dedup MARKER rows (``ack#…``) under the same + // derived id without ever being seeded, so "rows but no meta" is the normal + // shape there, not a broken orchestration — a plain `@bgagent` on any + // never-decomposed issue reaches this. Only warn when a REAL child row is + // present, which is the genuinely inconsistent case; otherwise this cried wolf + // on a healthy path and taught readers to ignore the log. + const hasRealChild = items.some((i) => !String(i.sub_issue_id).includes('#')); + const detail = { orchestration_id: orchestrationId, row_count: items.length }; + if (hasRealChild) logger.warn('Orchestration child rows present but the meta row is missing', detail); + else logger.info('No orchestration for this issue — only dedup markers exist', detail); + return null; + } + + const children = items + // Exclude the meta row AND non-child marker rows (e.g. ``ack#<commentId>`` + // dedup markers) — only real sub-issue rows are children. A real child SK is + // an issue id or the ``…__integration`` synthetic id; markers use a + // ``<kind>#`` prefix that no real SK has. + .filter((i) => i.sub_issue_id !== PARENT_META_SK && !String(i.sub_issue_id).includes('#')) + .map(toChildRow); + + const preScreened = parsePreScreenedAttachments(metaItem.pre_screened_attachments_json, orchestrationId); + + const meta: OrchestrationMeta = { + orchestration_id: orchestrationId, + // Renamed attributes — read either spelling so a pre-rename epic still settles. + parent_issue_ref: readRenamed(metaItem, 'parent_issue_ref', 'parent_linear_issue_id') ?? '', + credentials_ref: readRenamed(metaItem, 'credentials_ref', 'linear_workspace_id') ?? '', + repo: metaItem.repo as string, + child_count: (metaItem.child_count as number) ?? children.length, + release_context: { + platform_user_id: metaItem.platform_user_id as string, + ...(metaItem.channel_source !== undefined && { + channel_source: metaItem.channel_source as string, + }), + ...(metaItem.linear_oauth_secret_arn !== undefined && { + linear_oauth_secret_arn: metaItem.linear_oauth_secret_arn as string, + }), + ...(metaItem.linear_workspace_slug !== undefined && { + linear_workspace_slug: metaItem.linear_workspace_slug as string, + }), + ...(metaItem.linear_project_id !== undefined && { + linear_project_id: metaItem.linear_project_id as string, + }), + ...(preScreened.length > 0 && { pre_screened_attachments: preScreened }), + }, + ...(metaItem.retry_comment_id !== undefined && { + retry_comment_id: metaItem.retry_comment_id as string, + }), + ...(metaItem.status_comment_id !== undefined && { + status_comment_id: metaItem.status_comment_id as string, + }), + }; + + return { meta, children }; +} + +/** + * Resolve a released child by its head branch, via the ChildBranchIndex GSI. + * Maps a branch name back to the child row (which carries + * ``orchestration_id`` + ``sub_issue_id``). + * + * RETAINED, currently unused. This backed the original GitHub + * ``pull_request`` restack trigger, which was replaced by + * a Linear-comment trigger + reconciler-driven cascade (the cascade resolves + * the changed node by sub_issue_id, not by branch). The helper + its GSI are + * deliberately kept rather than removed: dropping a GSI is a + * CFN-update-unfriendly stack change for zero functional gain, and a + * branch→child lookup is a plausible future need (e.g. a branch-delete + * cleanup path). If it stays unused long-term, remove the helper and the GSI + * together in a dedicated migration. + * + * Returns the child row, or null if no released child owns that branch. The + * GSI is sparse — only released children carry ``child_branch_name`` — so a + * miss is the common, cheap case. ``indexName`` is injected (the CDK construct + * owns the literal) to keep this module free of a CDK dependency. + */ +export async function findOrchestrationChildByBranch( + ddb: DynamoDBDocumentClient, + tableName: string, + indexName: string, + branchName: string, +): Promise<OrchestrationChildRow | null> { + const res = await ddb.send(new QueryCommand({ + TableName: tableName, + IndexName: indexName, + KeyConditionExpression: 'child_branch_name = :b', + ExpressionAttributeValues: { ':b': branchName }, + Limit: 1, + })); + const item = res.Items?.[0]; + // Marshalled, not cast: a raw cast would type-check while leaving the renamed + // attributes unread, so a row written under either naming would come back with + // empty parent/credentials refs. + return item ? toChildRow(item) : null; +} diff --git a/cdk/src/handlers/shared/slack-api.ts b/cdk/src/handlers/shared/slack-api.ts index f76ba4c1c..71ef63b9d 100644 --- a/cdk/src/handlers/shared/slack-api.ts +++ b/cdk/src/handlers/shared/slack-api.ts @@ -70,3 +70,45 @@ export async function slackFetch( return false; } } + +/** + * POST to a Slack Web API method and return the message timestamp it produced. + * + * Distinct from {@link slackFetch} because a boolean is enough for fire-and-forget + * reactions but NOT for a message the caller must edit later: Slack addresses a + * message by its ``ts``, so a status panel that matures in place has to capture + * the one it created. Returns null on any failure (same best-effort contract). + */ +export async function slackFetchTs( + botToken: string, + method: string, + body: Record<string, unknown>, +): Promise<string | null> { + try { + const response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + logger.warn('Slack API returned non-2xx', { method, status: response.status }); + return null; + } + const result = await response.json() as { ok: boolean; error?: string; ts?: string }; + if (!result.ok) { + logger.warn('Slack API returned error', { method, error: result.error }); + return null; + } + // chat.update echoes the ts it edited; chat.postMessage returns the new one. + return result.ts ?? null; + } catch (err) { + logger.warn('Slack API fetch threw', { + method, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} diff --git a/cdk/test/constructs/orchestration-table.test.ts b/cdk/test/constructs/orchestration-table.test.ts new file mode 100644 index 000000000..3a69c1efe --- /dev/null +++ b/cdk/test/constructs/orchestration-table.test.ts @@ -0,0 +1,154 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { OrchestrationTable } from '../../src/constructs/orchestration-table'; + +describe('OrchestrationTable', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table with orchestration_id (PK) + sub_issue_id (SK)', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'orchestration_id', KeyType: 'HASH' }, + { AttributeName: 'sub_issue_id', KeyType: 'RANGE' }, + ], + }); + }); + + test('uses PAY_PER_REQUEST billing mode', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('enables point-in-time recovery by default', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('sets DESTROY removal policy by default', () => { + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); + + test('creates ChildTaskIndex GSI with child_task_id as PK', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ChildTaskIndex', + KeySchema: [ + { AttributeName: 'child_task_id', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('creates ChildBranchIndex GSI with child_branch_name as PK', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ChildBranchIndex', + KeySchema: [ + { AttributeName: 'child_branch_name', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('declares all required attribute definitions', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + AttributeDefinitions: Match.arrayWith([ + { AttributeName: 'orchestration_id', AttributeType: 'S' }, + { AttributeName: 'sub_issue_id', AttributeType: 'S' }, + { AttributeName: 'child_task_id', AttributeType: 'S' }, + { AttributeName: 'child_branch_name', AttributeType: 'S' }, + ]), + }); + }); + + test('static index name constants match actual GSI names', () => { + expect(OrchestrationTable.CHILD_TASK_INDEX).toBe('ChildTaskIndex'); + expect(OrchestrationTable.CHILD_BRANCH_INDEX).toBe('ChildBranchIndex'); + }); +}); + +describe('OrchestrationTable with custom props', () => { + test('accepts custom table name', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { tableName: 'my-orchestrations' }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + TableName: 'my-orchestrations', + }); + }); + + test('accepts custom removal policy', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { removalPolicy: RemovalPolicy.RETAIN }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + }); + + test('accepts point-in-time recovery disabled', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new OrchestrationTable(stack, 'OrchestrationTable', { pointInTimeRecovery: false }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: false, + }, + }); + }); +}); diff --git a/cdk/test/handlers/shared/iteration-reply.test.ts b/cdk/test/handlers/shared/iteration-reply.test.ts new file mode 100644 index 000000000..0103e127e --- /dev/null +++ b/cdk/test/handlers/shared/iteration-reply.test.ts @@ -0,0 +1,315 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + isNoChangeIteration, + isTerminalMaturingReply, + preservePreviewSuffix, + renderIterationSuccessReply, + renderMaturingReply, + renderPreviewBlock, +} from '../../../src/handlers/shared/iteration-reply'; + +describe('renderMaturingReply — the edit-in-place states', () => { + test('on_it → instant ack (no metadata)', () => { + expect(renderMaturingReply({ state: 'on_it' })).toBe('👀 On it — reading the PR…'); + }); + + test('working → names the PR being updated', () => { + expect(renderMaturingReply({ state: 'working', prNumber: 293 })).toBe('🔄 Working — updating PR #293…'); + expect(renderMaturingReply({ state: 'working' })).toBe('🔄 Working…'); + }); + + describe('liveness heartbeat on the working state', () => { + test('a fresh task (< 90s) shows the clean working line, no elapsed clause', () => { + expect(renderMaturingReply({ state: 'working', prNumber: 7, elapsedS: 30 })) + .toBe('🔄 Working — updating PR #7…'); + }); + + test('a long-running task shows "Nm elapsed" so it is not a silent black box', () => { + const r = renderMaturingReply({ state: 'working', prNumber: 7, elapsedS: 8 * 60 }); + expect(r).toContain('🔄 Working — updating PR #7…'); + expect(r).toContain('_8m elapsed_'); + }); + + test('a sanitized progress note is appended after elapsed', () => { + const r = renderMaturingReply({ + state: 'working', prNumber: 7, elapsedS: 5 * 60, progressNote: 'running build verification', + }); + expect(r).toContain('_5m elapsed · running build verification_'); + }); + + test('a progress note alone (no elapsed yet) still surfaces', () => { + const r = renderMaturingReply({ state: 'working', elapsedS: 10, progressNote: 'cloning repo' }); + // elapsed below floor → omitted; note still shown + expect(r).toContain('_cloning repo_'); + expect(r).not.toContain('elapsed'); + }); + + test('progress note whitespace is collapsed + over-long notes truncated', () => { + const r = renderMaturingReply({ + state: 'working', elapsedS: 200, progressNote: 'a'.repeat(200), + }); + // suffix line stays bounded (note capped at 80 + ellipsis) + const suffix = r.split('\n').pop()!; + expect(suffix.length).toBeLessThan(120); + expect(suffix.endsWith('…_')).toBe(true); + }); + + test('elapsed/note never appear on terminal states (working-only)', () => { + const updated = renderMaturingReply({ state: 'updated', prNumber: 7, elapsedS: 600, progressNote: 'x' }); + expect(updated).not.toContain('elapsed'); + expect(updated).not.toContain('\nx'); + }); + }); + + test('a PR url makes the reference a clickable markdown link', () => { + const w = renderMaturingReply({ state: 'working', prNumber: 293, prUrl: 'https://gh/pull/293' }); + expect(w).toBe('🔄 Working — updating [PR #293](https://gh/pull/293)…'); + const u = renderMaturingReply({ state: 'updated', prNumber: 293, prUrl: 'https://gh/pull/293' }); + expect(u).toContain('✅ Updated — [PR #293](https://gh/pull/293).'); + }); + + test('updated → ✅ + cost · duration · running total + clickable preview thumbnail', () => { + const r = renderMaturingReply({ + state: 'updated', + prNumber: 293, + costUsd: 0.79, + durationS: 309, + runningTotalUsd: 2.04, + screenshotUrl: 'https://cdn/x.png', + deployUrl: 'https://app.vercel.app', + }); + expect(r).toContain('✅ Updated — PR #293.'); + expect(r).toContain('$0.79'); + expect(r).toContain('5m 9s'); + expect(r).toContain('total this PR: $2.04'); + // Clickable image thumbnail: screenshot PNG embedded, linking to the deploy. + expect(r).toContain('[![preview](https://cdn/x.png)](https://app.vercel.app)'); + }); + + test('updated with screenshot but NO deploy url → plain embedded image (no link target)', () => { + const r = renderMaturingReply({ state: 'updated', prNumber: 7, screenshotUrl: 'https://cdn/y.png' }); + expect(r).toContain('![preview](https://cdn/y.png)'); + expect(r).not.toContain('[![preview]'); // not a link when no deploy url + }); + + test('updated with NO screenshot → no preview block at all', () => { + const r = renderMaturingReply({ state: 'updated', prNumber: 7, costUsd: 0.1 }); + expect(r).not.toContain('preview'); + expect(r).toContain('✅ Updated — PR #7.'); + }); + + test('answered → 💬 + the answer + cost (a question, no commit)', () => { + const r = renderMaturingReply({ state: 'answered', answerText: 'The login page is at /login.html', costUsd: 0.24 }); + expect(r).toContain('💬 The login page is at /login.html'); + expect(r).toContain('$0.24'); + expect(r).not.toContain('Updated'); + }); + + test('answered with no answer → honest no-change line', () => { + expect(renderMaturingReply({ state: 'answered' })).toContain('No code change was needed'); + }); + + test('failed → ❌ + sanitized reason', () => { + expect(renderMaturingReply({ state: 'failed', failureReason: 'build failed: tsc error' })) + .toContain('❌ build failed: tsc error'); + }); + + test('terminal metadata line omits unknown parts gracefully', () => { + // Only cost known → no duration, no total, no empty separators. + const r = renderMaturingReply({ state: 'updated', prNumber: 1, costUsd: 0.5 }); + expect(r).toContain('$0.50'); + expect(r).not.toContain('total this PR'); + expect(r).not.toMatch(/·\s*·/); // no doubled separators + }); + + test('on_it / working never carry a metadata line', () => { + expect(renderMaturingReply({ state: 'on_it', costUsd: 1 })).not.toContain('$'); + expect(renderMaturingReply({ state: 'working', costUsd: 1, prNumber: 2 })).not.toContain('$'); + }); +}); + +describe('renderIterationSuccessReply — changed (a real edit)', () => { + test('code changed + PR number → "✅ Updated — PR #N"', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: 290 })) + .toBe('✅ Updated — PR #290.'); + }); + + test('code changed + no PR number → "✅ Updated."', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: null })) + .toBe('✅ Updated.'); + }); + + test('codeChanged UNDEFINED (a caller that never sets it) → back-compat "✅ Updated — PR #N"', () => { + // The whole point of the back-compat default: anything that doesn't opt in + // behaves exactly as before. + expect(renderIterationSuccessReply({ prNumber: 178 })).toBe('✅ Updated — PR #178.'); + expect(renderIterationSuccessReply({})).toBe('✅ Updated.'); + }); + + test('an answer is IGNORED when code changed (the PR link is the signal)', () => { + expect(renderIterationSuccessReply({ codeChanged: true, prNumber: 5, answerText: 'irrelevant' })) + .toBe('✅ Updated — PR #5.'); + }); +}); + +describe('renderIterationSuccessReply — no change (a question)', () => { + test('no change + an answer → "💬 <answer>" (NOT a false ✅ Updated)', () => { + const r = renderIterationSuccessReply({ + codeChanged: false, + prNumber: 290, + answerText: 'The login page is at /login.html, but it is not yet linked from the nav.', + }); + expect(r).toBe('💬 The login page is at /login.html, but it is not yet linked from the nav.'); + expect(r).not.toContain('Updated'); + expect(r).not.toContain('290'); // a question reply must not imply a PR update + }); + + test('no change + NO answer → an honest "no change needed" (still not ✅ Updated)', () => { + const r = renderIterationSuccessReply({ codeChanged: false, prNumber: 290 }); + expect(r).toContain('No code change'); + expect(r).not.toContain('✅'); + }); + + test('a long answer is truncated with an ellipsis', () => { + const long = 'x'.repeat(5000); + const r = renderIterationSuccessReply({ codeChanged: false, answerText: long }); + // Cap is MAX_ANSWER_CHARS=2000 (aligned with the agent's persist cap so the + // renderer never drops chars the agent already bounded); '💬 ' prefix + ellipsis. + expect(r.length).toBeLessThanOrEqual(2003); + expect(r.length).toBeGreaterThan(1700); + expect(r.endsWith('…')).toBe(true); + }); + + test('whitespace-only answer falls back to the honest no-change line', () => { + const r = renderIterationSuccessReply({ codeChanged: false, answerText: ' ' }); + expect(r).toContain('No code change'); + }); +}); + +describe('isNoChangeIteration', () => { + test('only false counts as no-change (undefined/true do not)', () => { + expect(isNoChangeIteration(false)).toBe(true); + expect(isNoChangeIteration(true)).toBe(false); + expect(isNoChangeIteration(undefined)).toBe(false); + }); +}); + +describe('isTerminalMaturingReply — stop a late progress edit clobbering an outcome', () => { + // Two independent writers edit one reply: the terminal settle, and the + // stream-driven progress milestone. The milestone can be delivered AFTER the + // settle — observed just two seconds behind it — and the body is the ground + // truth about which has landed. + test('every TERMINAL render is recognised as settled', () => { + for (const state of ['updated', 'answered', 'failed'] as const) { + const body = renderMaturingReply({ + state, + prNumber: 42, + ...(state === 'answered' ? { answerText: 'no change needed' } : {}), + }); + expect(isTerminalMaturingReply(body)).toBe(true); + } + }); + + test('PROGRESS renders are NOT settled, so progress still flows', () => { + // If these came back true, a reply would freeze at "On it" forever. + for (const state of ['on_it', 'working'] as const) { + expect(isTerminalMaturingReply(renderMaturingReply({ state, prNumber: 42 }))).toBe(false); + } + }); + + test('a settled body with a preview block appended is still settled', () => { + // The screenshot webhook appends to the terminal body; that must not make it + // look un-settled. + const settled = renderMaturingReply({ state: 'updated', prNumber: 7 }); + expect(isTerminalMaturingReply(`${settled}\n\n[![preview](p.png)](https://d)`)).toBe(true); + }); + + test('an unknown or absent body is treated as NOT settled (fail open)', () => { + // Conservative on purpose: a missed progress edit is cosmetic, a reply stuck + // at "On it" is a black box. + expect(isTerminalMaturingReply(undefined)).toBe(false); + expect(isTerminalMaturingReply(null)).toBe(false); + expect(isTerminalMaturingReply('')).toBe(false); + expect(isTerminalMaturingReply('some other bot comment')).toBe(false); + }); + + test('leading whitespace does not hide a terminal marker', () => { + expect(isTerminalMaturingReply(' \n✅ Updated — PR #1.')).toBe(true); + }); +}); + +describe('preservePreviewSuffix — converge the two async writers of one reply', () => { + const PNG = 'https://cdn.example/screenshots/x.png'; + const DEPLOY = 'https://app.vercel.app'; + const BLOCK = `[![preview](${PNG})](${DEPLOY})`; + + test('carries an already-landed clickable thumbnail from current onto the new body', () => { + // The screenshot webhook appended the block; this terminal re-render would + // otherwise drop it. Convergence re-attaches the EXACT block on its own line. + const current = `✅ Updated — [PR #5](u). _$0.1_\n\n${BLOCK}`; + const newBody = '✅ Updated — [PR #5](u). _$0.2 · 35s · total this PR: $0.5_'; + expect(preservePreviewSuffix(newBody, current)).toBe(`${newBody}\n\n${BLOCK}`); + }); + + test('preserves a plain embed too (screenshot, no deploy link)', () => { + const current = `✅ Updated.\n\n![preview](${PNG})`; + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + expect(preservePreviewSuffix(newBody, current)).toBe(`${newBody}\n\n![preview](${PNG})`); + }); + + test('no-op when the new body already carries its own preview (settle-after-append)', () => { + const current = `✅ Updated.\n\n${BLOCK}`; + const newBody = `✅ Updated — [PR #5](u).\n\n${BLOCK}`; + expect(preservePreviewSuffix(newBody, current)).toBe(newBody); // not doubled + }); + + test('no-op when current has no preview (the common no-deploy case)', () => { + const current = '👀 On it — reading the PR…'; + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + expect(preservePreviewSuffix(newBody, current)).toBe(newBody); + }); + + test('null/undefined current → returns new body unchanged', () => { + expect(preservePreviewSuffix('✅ Updated.', null)).toBe('✅ Updated.'); + expect(preservePreviewSuffix('✅ Updated.', undefined)).toBe('✅ Updated.'); + }); + + test('idempotent across repeated settles', () => { + const newBody = '✅ Updated — [PR #5](u). _$0.2_'; + const once = preservePreviewSuffix(newBody, `x\n\n${BLOCK}`); + const twice = preservePreviewSuffix(once, once); // current now already has it + expect(twice).toBe(once); + }); +}); + +describe('renderPreviewBlock — clickable thumbnail vs plain embed', () => { + test('both urls → clickable image thumbnail', () => { + expect(renderPreviewBlock('https://cdn/s.png', 'https://deploy')).toBe('[![preview](https://cdn/s.png)](https://deploy)'); + }); + test('screenshot only → plain embed', () => { + expect(renderPreviewBlock('https://cdn/s.png')).toBe('![preview](https://cdn/s.png)'); + expect(renderPreviewBlock('https://cdn/s.png', null)).toBe('![preview](https://cdn/s.png)'); + }); + test('no screenshot → empty', () => { + expect(renderPreviewBlock(null, 'https://deploy')).toBe(''); + expect(renderPreviewBlock(undefined)).toBe(''); + }); +}); diff --git a/cdk/test/handlers/shared/linear-feedback.test.ts b/cdk/test/handlers/shared/linear-feedback.test.ts index 3a19f4d93..c5547b262 100644 --- a/cdk/test/handlers/shared/linear-feedback.test.ts +++ b/cdk/test/handlers/shared/linear-feedback.test.ts @@ -28,9 +28,21 @@ const fetchMock = jest.fn(); import { addIssueReaction, + appendOnceToComment, type LinearFeedbackContext, + deleteComment, + fetchRecentComments, postIssueComment, + reactToComment, + replyToComment, reportIssueFailure, + revertIssueToNotStarted, + sweepDecompositionNotes, + swapCommentReaction, + swapIssueReaction, + transitionIssueState, + upsertStatusComment, + upsertThreadedReply, } from '../../../src/handlers/shared/linear-feedback'; const CTX: LinearFeedbackContext = { @@ -71,7 +83,7 @@ describe('linear-feedback', () => { expect(url).toBe('https://api.linear.app/graphql'); expect(init.method).toBe('POST'); expect(init.headers).toMatchObject({ - // OAuth tokens use Bearer prefix per Phase 2.0b-O2. + // OAuth access tokens are sent with the Bearer prefix. 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }); @@ -158,6 +170,91 @@ describe('linear-feedback', () => { }); }); + describe('reactToComment — instant "on it" acknowledgement on the comment itself', () => { + test('reacts on the COMMENT (commentId), defaulting to 👀 (eyes)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { reactionCreate: { success: true } } })); + + const ok = await reactToComment(CTX, 'comment-77'); + + expect(ok).toBe(true); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { query: string; variables: { commentId: string; emoji: string } }; + expect(body.query).toContain('reactionCreate'); + // The variable is commentId — NOT issueId (reacts on the comment, not the issue). + expect(body.variables.commentId).toBe('comment-77'); + expect(body.variables.emoji).toBe('eyes'); + }); + + test('honours an explicit emoji argument', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { reactionCreate: { success: true } } })); + await reactToComment(CTX, 'comment-77', 'white_check_mark'); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { variables: { emoji: string } }; + expect(body.variables.emoji).toBe('white_check_mark'); + }); + + test('returns false when the token cannot be resolved (no fetch)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await reactToComment(CTX, 'comment-77'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false on network failure (swallowed)', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); + const ok = await reactToComment(CTX, 'comment-77'); + expect(ok).toBe(false); + }); + }); + + describe('replyToComment — threaded reply that notifies the commenter', () => { + test('POSTs commentCreate with BOTH issueId and parentId, returns the new reply id', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'reply-99' } } } })); + + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', '✅ Updated — PR #178'); + + expect(replyId).toBe('reply-99'); + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body as string) as { query: string; variables: { issueId: string; parentId: string; body: string } }; + expect(body.query).toContain('commentCreate'); + // CONTRACT (verified against the live Linear API): commentCreate REQUIRES + // issueId even for a threaded reply — parentId alone fails argument + // validation. Pin BOTH so the missing-issueId regression can't return. + expect(body.variables.issueId).toBe(ISSUE_ID); + expect(body.variables.parentId).toBe('comment-77'); + expect(body.variables.body).toBe('✅ Updated — PR #178'); + }); + + test('the mutation declares issueId as a required argument (regression guard)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'r' } } } })); + await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + const init = fetchMock.mock.calls[0][1]; + const query = (JSON.parse(init.body as string) as { query: string }).query; + // The GraphQL op must pass issueId INTO commentCreate's input — not just + // accept it as a variable. Catches a half-fix that drops it from input. + expect(query).toMatch(/commentCreate\(\s*input:\s*\{[^}]*issueId:\s*\$issueId/); + }); + + test('returns null when commentCreate did not succeed', async () => { + fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: false } } })); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + }); + + test('returns null on GraphQL errors (no throw)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'parent not found' }] })); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + }); + + test('returns null when the token cannot be resolved (no fetch)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const replyId = await replyToComment(CTX, ISSUE_ID, 'comment-77', 'body'); + expect(replyId).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + describe('reportIssueFailure', () => { test('posts comment + ❌ in parallel via Promise.allSettled', async () => { await reportIssueFailure(CTX, ISSUE_ID, '❌ failed'); @@ -186,4 +283,772 @@ describe('linear-feedback', () => { await expect(reportIssueFailure(CTX, ISSUE_ID, 'msg')).resolves.toBeUndefined(); }); }); + + describe('swapIssueReaction — exactly one status marker on the issue at a time', () => { + const reactionsResp = (rs: Array<{ id: string; emoji: string }>) => + jsonResponse({ data: { issue: { reactions: rs } } }); + + test('👀 present → deletes it and adds the target (✅)', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) // query + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // delete 👀 + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); // add ✅ + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + expect(ok).toBe(true); + const deleteVars = JSON.parse(fetchMock.mock.calls[1][1].body).variables; + expect(deleteVars).toEqual({ id: 'r-eyes' }); + const createVars = JSON.parse(fetchMock.mock.calls[2][1].body).variables; + expect(createVars).toEqual({ issueId: ISSUE_ID, emoji: 'white_check_mark' }); + }); + + test('target already present → deletes other bgagent markers, does NOT re-create', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })); // delete 👀 only + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + expect(ok).toBe(true); + // 1 query + 1 delete (the 👀); no create (✅ already there). + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({ id: 'r-eyes' }); + }); + + test('never deletes a human (non-bgagent) reaction', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-tada', emoji: 'tada' }, // human reaction — must survive + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // delete 👀 + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); // add ✅ + await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark'); + const deletedIds = fetchMock.mock.calls + .filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds).toEqual(['r-eyes']); // only the bgagent marker, never r-tada + }); + + test('no existing markers → just adds the target', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + const ok = await swapIssueReaction(CTX, ISSUE_ID, 'eyes'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); // query + create, no deletes + }); + + test('a TRANSIENT delete failure is retried once, and then succeeds', async () => { + // Observed in practice: a 5s request timeout aborted the 👀 removal, so the + // comment kept 👀 beside ❓. No caller inspects this result and nothing + // revisits the item, so that contradiction was permanent — one retry + // covers the blip. + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) // delete: transient + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) // retry wins + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(true); + const deletes = fetchMock.mock.calls.filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')); + expect(deletes).toHaveLength(2); // the failed attempt plus one retry + }); + + test('a TERMINAL delete failure is NOT retried — a resend cannot change the answer', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })) // terminal + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + const deletes = fetchMock.mock.calls.filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')); + expect(deletes).toHaveLength(1); + }); + + test('a retry that ALSO fails still reports failure', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + }); + + test('a delete that FAILS is reported as failure, even though the target was added', async () => { + // Returning only the add's result would claim the promised single marker + // while the issue still shows 👀 beside ✅ — the contradictory state this + // swap exists to prevent, and what a caller checks the result to rule out. + fetchMock + .mockResolvedValueOnce(reactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })) // delete 👀 fails + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + // The target is still attempted — one correct marker beats only a stale one. + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('target already present but a stale marker survives → failure', async () => { + fetchMock + .mockResolvedValueOnce(reactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'white_check_mark')).toBe(false); + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await swapIssueReaction(CTX, ISSUE_ID, 'eyes')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('swapCommentReaction — settle the trigger comment 👀→✅/❌', () => { + const commentReactionsResp = (rs: Array<{ id: string; emoji: string }>) => + jsonResponse({ data: { comment: { reactions: rs } } }); + + test('👀 on the comment → deletes it and adds ✅ (on the COMMENT, not the issue)', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + const ok = await swapCommentReaction(CTX, 'comment-77', 'white_check_mark'); + expect(ok).toBe(true); + // query targets the COMMENT + expect(JSON.parse(fetchMock.mock.calls[0][1].body).variables).toEqual({ commentId: 'comment-77' }); + // delete the stale 👀 + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({ id: 'r-eyes' }); + // create the ✅ via reactionCreate(commentId) + const createVars = JSON.parse(fetchMock.mock.calls[2][1].body).variables; + expect(createVars).toEqual({ commentId: 'comment-77', emoji: 'white_check_mark' }); + }); + + test('target already present → no re-create (idempotent under redelivery)', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-check', emoji: 'white_check_mark' }, + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })); + const ok = await swapCommentReaction(CTX, 'comment-77', 'white_check_mark'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); // query + delete 👀; ✅ already present + }); + + test('the COMMENT swap retries a transient removal too', async () => { + // The failure this covers was seen on a comment: 👀 + ❓ both left on a + // disambiguation reply, because the removal timed out and nothing ever + // tried again. + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapCommentReaction(CTX, 'comment-77', 'question')).toBe(true); + }); + + test('a delete that FAILS on the comment is reported as failure too', async () => { + // Both swaps implement one interface method, so they must report the same + // all-or-nothing result; a caller cannot branch per surface. + fetchMock + .mockResolvedValueOnce(commentReactionsResp([{ id: 'r-eyes', emoji: 'eyes' }])) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'nope' }] })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + expect(await swapCommentReaction(CTX, 'comment-77', 'white_check_mark')).toBe(false); + }); + + test('never deletes a human reaction on the comment', async () => { + fetchMock + .mockResolvedValueOnce(commentReactionsResp([ + { id: 'r-eyes', emoji: 'eyes' }, + { id: 'r-heart', emoji: 'heart' }, // human — must survive + ])) + .mockResolvedValueOnce(jsonResponse({ data: { reactionDelete: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); + await swapCommentReaction(CTX, 'comment-77', 'x'); + const deletedIds = fetchMock.mock.calls + .filter((c) => JSON.parse(c[1].body).query.includes('reactionDelete')) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds).toEqual(['r-eyes']); // never r-heart + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await swapCommentReaction(CTX, 'comment-77', 'eyes')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('upsertStatusComment — one live status comment edited in place', () => { + test('no existing id → creates a comment and returns the new id', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { commentCreate: { success: true, comment: { id: 'cmt-new' } } } }), + ); + const id = await upsertStatusComment(CTX, ISSUE_ID, 'body'); + expect(id).toBe('cmt-new'); + // create mutation carries issueId + body + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ issueId: ISSUE_ID, body: 'body' }); + }); + + test('existing id → edits in place and returns the same id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertStatusComment(CTX, ISSUE_ID, 'new body', 'cmt-existing'); + expect(id).toBe('cmt-existing'); + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ id: 'cmt-existing', body: 'new body' }); + }); + + test('create reporting success:false → null', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentCreate: { success: false } } })); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body')).toBeNull(); + }); + + test('update GraphQL failure → null (does not fabricate the id)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body', 'cmt-x')).toBeNull(); + }); + + test('no token → null, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await upsertStatusComment(CTX, ISSUE_ID, 'body')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('deleteComment — remove a transient acknowledgement comment', () => { + test('success → true, sends commentDelete with the id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentDelete: { success: true } } })); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(true); + const vars = JSON.parse(fetchMock.mock.calls[0][1].body).variables; + expect(vars).toEqual({ id: 'cmt-ack' }); + }); + + test('GraphQL error → false (best-effort, never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'not found' }] })); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(false); + }); + + test('no token → false, no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + expect(await deleteComment(CTX, 'cmt-ack')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('transitionIssueState', () => { + // Mirrors a typical Linear team's workflow states (by type + position). + const TEAM_STATES = [ + { id: 's-backlog', type: 'backlog', name: 'Backlog', position: 0 }, + { id: 's-todo', type: 'unstarted', name: 'Todo', position: 1 }, + { id: 's-inprogress', type: 'started', name: 'In Progress', position: 2 }, + { id: 's-inreview', type: 'started', name: 'In Review', position: 1002 }, + { id: 's-done', type: 'completed', name: 'Done', position: 3 }, + ]; + const statesResp = (current: { id: string; type: string; name: string; position: number }) => + jsonResponse({ data: { issue: { state: current, team: { states: { nodes: TEAM_STATES } } } } }); + const cur = (id: string) => TEAM_STATES.find((s) => s.id === id)!; + + test('Backlog → In Progress: picks the named started state, issues issueUpdate', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-backlog'))) // team-states query + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress']); + expect(ok).toBe(true); + // second call is the mutation with the resolved stateId + const mutationVars = JSON.parse(fetchMock.mock.calls[1][1].body).variables; + expect(mutationVars).toEqual({ issueId: ISSUE_ID, stateId: 's-inprogress' }); + }); + + test('In Progress → In Review: name preference wins over position among started states', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inprogress'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-inreview'); + }); + + test('already in target state → no mutation, returns false', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-inreview'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // only the query, no mutation + }); + + test('never moves backward: Done (completed) is not demoted to In Review', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + // The parent rollup's re-open (In Review → In Progress — BOTH of type + // 'started') was silently blocked by the same-type position tiebreak, so + // both directions of that tiebreak are pinned below. + test('same-type regression In Review → In Progress is BLOCKED by default', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-inreview'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress']); + expect(ok).toBe(false); // In Progress (pos 2) < In Review (pos 1002) → backward + expect(fetchMock).toHaveBeenCalledTimes(1); // no mutation + }); + + test('same-type regression In Review → In Progress SUCCEEDS with allowSameTypeRegression (rollup re-open)', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inreview'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-inprogress'); + }); + + test('allowSameTypeRegression does NOT permit a cross-type demotion (Done → In Progress still blocked)', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true); + expect(ok).toBe(false); // completed → started is cross-type; still refused + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('returns false when token cannot be resolved', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false when the team has no state of the target type', async () => { + const noCompleted = TEAM_STATES.filter((s) => s.type !== 'completed'); + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { issue: { state: cur('s-inprogress'), team: { states: { nodes: noCompleted } } } } }), + ); + const ok = await transitionIssueState(CTX, ISSUE_ID, 'completed'); + expect(ok).toBe(false); + }); + }); + + describe('revertIssueToNotStarted — undo our own In Progress when work did not start', () => { + const TEAM_STATES = [ + { id: 's-backlog', type: 'backlog', name: 'Backlog', position: 0 }, + { id: 's-todo', type: 'unstarted', name: 'Todo', position: 1 }, + { id: 's-inprogress', type: 'started', name: 'In Progress', position: 2 }, + { id: 's-done', type: 'completed', name: 'Done', position: 3 }, + ]; + const statesResp = (current: { id: string; type: string; name: string; position: number }) => + jsonResponse({ data: { issue: { state: current, team: { states: { nodes: TEAM_STATES } } } } }); + const cur = (id: string) => TEAM_STATES.find((s) => s.id === id)!; + + test('In Progress → Todo: our In-Progress reverts to the unstarted state', async () => { + fetchMock + .mockResolvedValueOnce(statesResp(cur('s-inprogress'))) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-todo'); + }); + + test('falls back to Backlog when the team has no unstarted state', async () => { + const noUnstarted = TEAM_STATES.filter((s) => s.type !== 'unstarted'); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ data: { issue: { state: cur('s-inprogress'), team: { states: { nodes: noUnstarted } } } } }), + ) + .mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } })); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(true); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-backlog'); + }); + + test('does NOT demote a human-completed issue (only reverts a started state)', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-done'))); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // query only, no mutation + }); + + test('no-op when the issue is already in a not-started (backlog) state', async () => { + fetchMock.mockResolvedValueOnce(statesResp(cur('s-backlog'))); + const ok = await revertIssueToNotStarted(CTX, ISSUE_ID); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('appendOnceToComment — add the preview link to a reply at most once', () => { + const COMMENT_ID = 'reply-cmt-1'; + + test('reads the body and appends the line when the marker is absent', async () => { + // 1st fetch = read body; 2nd = commentUpdate. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u). _$0.1_' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](https://cdn/x.png)', '[preview]'); + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + // The update carries the original body + the appended line. + const updateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(updateBody.variables.body).toBe('✅ Updated — [PR #5](u). _$0.1_\n · [preview](https://cdn/x.png)'); + expect(updateBody.variables.id).toBe(COMMENT_ID); + }); + + test('idempotent: marker already present → no update (webhook redelivery)', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u). · [preview](https://cdn/x.png)' } } }), + ); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](https://cdn/y.png)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); // read only, NO update + }); + + test('missing comment body → no update, returns false', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { comment: null } })); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](u)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('no token → no fetch', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const ok = await appendOnceToComment(CTX, COMMENT_ID, ' · [preview](u)', '[preview]'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('upsertThreadedReply preservePreview — converge concurrent writers of one reply', () => { + const REPLY_ID = 'reply-cmt-9'; + const BLOCK = '[![preview](https://cdn/screenshots/x.png)](https://app.vercel.app)'; + + test('terminal edit carries an already-landed preview thumbnail from the current body', async () => { + // The screenshot webhook appended the clickable thumbnail block first; this + // terminal re-render reads the current body and re-attaches it, so the + // two writers converge instead of one dropping the other's content. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `✅ Updated.\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const newBody = '✅ Updated — [PR #5](u). _$0.2 · 35s_'; + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', newBody, REPLY_ID, { preservePreview: true }); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); // read body, then update + const updateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(updateBody.variables.body).toBe(`${newBody}\n\n${BLOCK}`); + }); + + test('skipIfSettled REFUSES a progress edit over an already-settled reply', async () => { + // The failure this fixes: a stream-delivered "working" milestone landed + // AFTER the terminal settle and overwrote "✅ Updated", so the reply + // contradicted both the trigger comment's ✅ reaction and reality. + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '✅ Updated — [PR #5](u).' } } })); + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working — updating PR #5…', REPLY_ID, { skipIfSettled: true }, + ); + // Reports the reply id (the caller's intent is satisfied — it IS up to date) + // but performs NO update mutation. + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // the body read only + }); + + test('skipIfSettled still allows a progress edit while the reply is un-settled', async () => { + // The common case: the reply is at "On it" and this is the first progress + // tick. Suppressing here would freeze the reply and re-create the black box. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '👀 On it — reading the PR…' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const progress = '🔄 Working — updating PR #5…'; + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', progress, REPLY_ID, { skipIfSettled: true }, + ); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.body).toBe(progress); + }); + + test('a TERMINAL edit never sets skipIfSettled, so an outcome can always overwrite progress', async () => { + // Direction matters: progress yields to an outcome, never the reverse. + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', '✅ Updated.', REPLY_ID); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // straight update, no read + }); + + test('both options together read the body ONCE', async () => { + // The terminal settle asks for preview preservation; a progress edit asks + // for the settle check. Neither should double-read. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `👀 On it…\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working…', REPLY_ID, + { preservePreview: true, skipIfSettled: true }, + ); + expect(fetchMock).toHaveBeenCalledTimes(2); // one read + one update + }); + + test('without preservePreview the edit does NOT read the body (single update)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + const id = await upsertThreadedReply(CTX, ISSUE_ID, 'parent-1', '✅ Updated.', REPLY_ID); + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); // straight update, no read + }); + }); + + describe('upsertThreadedReply repairIfOverwritten (the outcome defends itself)', () => { + const REPLY_ID = 'reply-cmt-9'; + const BLOCK = '[![preview](https://cdn/screenshots/x.png)](https://app.vercel.app)'; + const OUTCOME = '✅ Updated — [PR #5](u). _$0.2 · 35s_'; + + test('restores the outcome when a racing progress edit overwrote it', async () => { + // Why the progress writer's own body check is not enough: it reads, then + // writes, and a settle landing in between is still overwritten. Linear's + // comment update takes only an id and a body — no version to condition on — + // so the interleaving cannot be prevented, only detected and undone by the + // writer that knows which body matters. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) // settle write + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '🔄 Working — updating PR #5…' } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); // repair + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(JSON.parse(fetchMock.mock.calls[2][1].body).variables.body).toBe(OUTCOME); + }); + + test('the repair carries over a preview appended during the race', async () => { + // The screenshot webhook is a third writer of this reply; restoring the + // outcome must not drop a thumbnail that landed while the race resolved. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: `🔄 Working…\n\n${BLOCK}` } } })) + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(JSON.parse(fetchMock.mock.calls[2][1].body).variables.body).toBe(`${OUTCOME}\n\n${BLOCK}`); + }); + + test('no repair when the outcome is still there (one extra read, no write)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: OUTCOME } } })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); // write + verify read, no second write + }); + + test('a DIFFERENT terminal body is left alone — never fight a later outcome', async () => { + // A failure reply superseding a success, or a second iteration's settle, is + // newer and correct. Re-asserting our own would undo real information. + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '❌ The build failed.' } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); // no repair write + }); + + test('an unreadable body is left alone rather than guessed at', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + // The settle itself succeeded, so report success; the verify is advisory. + expect(id).toBe(REPLY_ID); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('a PROGRESS body is never defended — only outcomes are worth the round trip', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })); + + await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', '🔄 Working…', REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); // no verify read at all + }); + + test('a failed settle is reported as failed and never repaired', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'bad id' }] })); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('a repair that itself fails does not fail the settle', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: { commentUpdate: { success: true } } })) + .mockResolvedValueOnce(jsonResponse({ data: { comment: { body: '🔄 Working…' } } })) + .mockResolvedValueOnce(jsonResponse({}, 500)); + + const id = await upsertThreadedReply( + CTX, ISSUE_ID, 'parent-1', OUTCOME, REPLY_ID, { repairIfOverwritten: true }, + ); + + expect(id).toBe(REPLY_ID); + }); + }); + + describe('sweepDecompositionNotes — tidy the planning chatter once a plan is approved', () => { + // A representative plan-phase thread: the frozen plan reference (KEEP), the + // transient decompose notes (🗂️/👋 → DELETE), the live epic panel (🔄 → a + // different prefix, KEEP), and a human comment (no bot prefix, KEEP). + const THREAD = { + data: { + issue: { + comments: { + nodes: [ + { id: 'plan-ref', body: '🗂️ **Approved plan** — 2 sub-issues' }, + { id: 'started-ack', body: '🗂️ On it — working out how to break this up…' }, + { id: 'nudge', body: '👋 I answer to `@bgagent`…' }, + { id: 'panel', body: '🔄 **ABCA orchestration** · 0/2 complete' }, + { id: 'human', body: 'looks good, ship it' }, + ], + }, + }, + }, + }; + + test('deletes the transient 🗂️/👋 notes, keeps the frozen reference + panel + human comment', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(THREAD)) // the comments list + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + // started-ack + nudge deleted; plan-ref (kept), panel (🔄), human (no prefix) survive. + expect(deleted).toBe(2); + const deletedIds = fetchMock.mock.calls + .slice(1) + .map((c) => JSON.parse(c[1].body).variables.id); + expect(deletedIds.sort()).toEqual(['nudge', 'started-ack']); + expect(deletedIds).not.toContain('plan-ref'); + expect(deletedIds).not.toContain('panel'); + expect(deletedIds).not.toContain('human'); + }); + + test('with no keepCommentId, sweeps ALL bot notes incl. the untracked reference (nothing to preserve)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(THREAD)) + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID); + // plan-ref + started-ack + nudge (all 🗂️/👋); panel + human still spared. + expect(deleted).toBe(3); + }); + + test('no token → no fetch, sweeps nothing', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('a failed comments-list is a clean no-op (best-effort, never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] }, 200)); + const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); // only the (failed) list; no deletes + }); + + test('a leading-whitespace bot note is still matched (trimStart)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: { issue: { comments: { nodes: [{ id: 'ws', body: '\n 🗂️ over-cap note' }] } } }, + })) + .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); + const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + expect(deleted).toBe(1); + }); + }); + + describe('fetchRecentComments — pre-hydrate the human discussion for the agent', () => { + function commentsResponse(nodes: unknown[]): Response { + return jsonResponse({ data: { issue: { comments: { nodes } } } }); + } + + test('returns human comments oldest-first, rendered with author + timestamp', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'c2', body: 'second', createdAt: '2026-07-20T10:00:00Z', user: { displayName: 'Bob' } }, + { id: 'c1', body: 'first', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([ + { author: 'Alice', createdAt: '2026-07-19T09:00:00Z', markdown: 'first' }, + { author: 'Bob', createdAt: '2026-07-20T10:00:00Z', markdown: 'second' }, + ]); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { query: string; variables: Record<string, string> }; + expect(body.query).toContain('botActor'); + expect(body.variables).toEqual({ issueId: ISSUE_ID }); + }); + + test('drops app/integration comments (botActor present, or no user)', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'h', body: 'human turn', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + { id: 'b', body: 'bot progress', createdAt: '2026-07-19T09:05:00Z', botActor: { id: 'app-1' } }, + { id: 'n', body: 'no author', createdAt: '2026-07-19T09:06:00Z', user: null }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toHaveLength(1); + expect(result[0].markdown).toBe('human turn'); + }); + + test('drops bot-prefixed bodies even if attributed to a user (belt + suspenders)', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'p', body: '🤖 Starting…', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Someone' } }, + { id: 'h', body: 'real question', createdAt: '2026-07-19T09:01:00Z', user: { displayName: 'Alice' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result.map((c) => c.markdown)).toEqual(['real question']); + }); + + test('keeps only the most recent maxComments', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + body: `comment ${i}`, + createdAt: `2026-07-2${i}T00:00:00Z`, + user: { displayName: 'Alice' }, + })); + fetchMock.mockResolvedValueOnce(commentsResponse(nodes)); + const capped = await fetchRecentComments(CTX, ISSUE_ID, 2); + expect(capped.map((c) => c.markdown)).toEqual(['comment 3', 'comment 4']); + }); + + test('fail-open: no token → [] (never throws)', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('fail-open: GraphQL error → [] (never throws)', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] })); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result).toEqual([]); + }); + + test('skips comments with empty/whitespace bodies', async () => { + fetchMock.mockResolvedValueOnce(commentsResponse([ + { id: 'e', body: ' ', createdAt: '2026-07-19T09:00:00Z', user: { displayName: 'Alice' } }, + { id: 'h', body: 'kept', createdAt: '2026-07-19T09:01:00Z', user: { displayName: 'Bob' } }, + ])); + const result = await fetchRecentComments(CTX, ISSUE_ID); + expect(result.map((c) => c.markdown)).toEqual(['kept']); + }); + }); }); diff --git a/cdk/test/handlers/shared/linear-issue-lookup.test.ts b/cdk/test/handlers/shared/linear-issue-lookup.test.ts index 0635920d1..1d20951e3 100644 --- a/cdk/test/handlers/shared/linear-issue-lookup.test.ts +++ b/cdk/test/handlers/shared/linear-issue-lookup.test.ts @@ -17,12 +17,14 @@ * SOFTWARE. */ -// `findLinearIssueByIdentifier` is covered by PR #275's broader test -// suite (mocks the registry scan + GraphQL); this file only locks in -// `extractLinearIdentifier` since it's a pure function and the g-flag -// regex's `lastIndex` reset behavior is easy to regress across releases. +// `findLinearIssueByIdentifier` needs the registry scan and the GraphQL call +// mocked, so it is covered elsewhere. This file locks in the pure identifier +// parsers, whose g-flagged regex `lastIndex` reset behavior is easy to regress. -import { extractLinearIdentifier } from '../../../src/handlers/shared/linear-issue-lookup'; +import { + extractLinearIdentifier, + extractLinearIdentifierFromBranch, +} from '../../../src/handlers/shared/linear-issue-lookup'; describe('extractLinearIdentifier', () => { test('returns null for null / undefined / empty input', () => { @@ -57,10 +59,10 @@ describe('extractLinearIdentifier', () => { expect(extractLinearIdentifier('ABCA-1234567890')).toBeNull(); }); - // The regex is g-flagged at module scope. - // scope, which means `RegExp.prototype.exec` carries `lastIndex` - // across calls. The implementation explicitly resets it; this test - // pins the behavior so nobody removes the reset thinking it's dead. + // The regex is g-flagged and lives at module scope, which means + // `RegExp.prototype.exec` carries `lastIndex` across calls. The + // implementation explicitly resets it; this test pins the behavior so + // nobody removes the reset thinking it's dead code. test('back-to-back calls do not skip due to leftover g-flag lastIndex', () => { // Run the same call twice — without the explicit reset, the second // call would start scanning from where the first call left off and @@ -78,3 +80,32 @@ describe('extractLinearIdentifier', () => { expect(extractLinearIdentifier('fourth ABCA-1')).toBe('ABCA-1'); }); }); + +describe('extractLinearIdentifierFromBranch', () => { + test('pulls the canonical identifier from an agent task branch (lowercased slug)', () => { + // Branch shape is bgagent/{taskId}/{slug}, where the slug is a slugified + // "<identifier>: <issue title>" and so has lost the original casing. + expect( + extractLinearIdentifierFromBranch('bgagent/01KTSK8XGXHRMT0JX44GYRPJG7/abca-151-add-lisbon-guidehtml'), + ).toBe('ABCA-151'); + }); + + test('the ULID task-id segment does not false-match before the identifier', () => { + // The ULID has no dash, so it cannot produce a <KEY>-<n> match; the + // first real match is the issue identifier in the slug. + expect( + extractLinearIdentifierFromBranch('bgagent/01KTSKET9040HDJP3P2QE15DXC/abca-152-link-lisbon-from-destinationsht'), + ).toBe('ABCA-152'); + }); + + test('returns null for a branch with no identifier', () => { + expect(extractLinearIdentifierFromBranch('bgagent/01TASK/task')).toBeNull(); + expect(extractLinearIdentifierFromBranch('feature/some-thing')).toBeNull(); + }); + + test('returns null on null/undefined/empty', () => { + expect(extractLinearIdentifierFromBranch(null)).toBeNull(); + expect(extractLinearIdentifierFromBranch(undefined)).toBeNull(); + expect(extractLinearIdentifierFromBranch('')).toBeNull(); + }); +}); diff --git a/cdk/test/handlers/shared/linear-subissue-fetch.test.ts b/cdk/test/handlers/shared/linear-subissue-fetch.test.ts new file mode 100644 index 000000000..285b8abea --- /dev/null +++ b/cdk/test/handlers/shared/linear-subissue-fetch.test.ts @@ -0,0 +1,215 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { fetchSubIssueGraph } from '../../../src/handlers/shared/linear-subissue-fetch'; + +/** Build a mock fetch returning a given JSON body + ok/status. */ +function mockFetch(body: unknown, init: { ok?: boolean; status?: number } = {}): typeof fetch { + return (async () => ({ + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => body, + })) as unknown as typeof fetch; +} + +/** Shape a Linear `issue.children` GraphQL response. */ +function graphResponse(children: Array<{ + id: string; + identifier?: string; + title?: string; + blockedBy?: string[]; // ids that block this child (inverseRelations type "blocks") +}>) { + return { + data: { + issue: { + id: 'PARENT', + children: { + nodes: children.map((c) => ({ + id: c.id, + identifier: c.identifier, + title: c.title, + inverseRelations: { + nodes: (c.blockedBy ?? []).map((bid) => ({ type: 'blocks', issue: { id: bid } })), + }, + })), + }, + }, + }, + }; +} + +describe('fetchSubIssueGraph — success shapes', () => { + test('maps children and blockedBy edges into depends_on', async () => { + const fetchImpl = mockFetch(graphResponse([ + { id: 'A', identifier: 'ENG-1', title: 'Root' }, + { id: 'B', identifier: 'ENG-2', title: 'Blocked by A', blockedBy: ['A'] }, + ])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.parentIssueId).toBe('PARENT'); + expect(result.children).toEqual([ + { id: 'A', identifier: 'ENG-1', title: 'Root', depends_on: [] }, + { id: 'B', identifier: 'ENG-2', title: 'Blocked by A', depends_on: ['A'] }, + ]); + } + }); + + test('drops blockedBy edges that point outside the child set', async () => { + // C is blocked by GHOST (not a sibling) — edge dropped. + const fetchImpl = mockFetch(graphResponse([ + { id: 'C', blockedBy: ['GHOST'] }, + ])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[0].depends_on).toEqual([]); + }); + + test('ignores relation types other than "blocks"', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: [ + { id: 'A' }, + { + id: 'B', + inverseRelations: { + nodes: [ + { type: 'related', issue: { id: 'A' } }, // not a blocker + { type: 'duplicate', issue: { id: 'A' } }, + ], + }, + }, + ], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[1].depends_on).toEqual([]); + }); + + test('dedups duplicate blocker edges', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: [ + { id: 'A' }, + { + id: 'B', + inverseRelations: { + nodes: [ + { type: 'blocks', issue: { id: 'A' } }, + { type: 'blocks', issue: { id: 'A' } }, + ], + }, + }, + ], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[1].depends_on).toEqual(['A']); + }); + + test('ignores a self-blocking edge from the raw payload', async () => { + const fetchImpl = mockFetch(graphResponse([{ id: 'A', blockedBy: ['A'] }])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + if (result.kind === 'ok') expect(result.children[0].depends_on).toEqual([]); + }); +}); + +describe('fetchSubIssueGraph — no children', () => { + test('returns no_children when the issue has an empty children set', async () => { + const fetchImpl = mockFetch(graphResponse([])); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('no_children'); + if (result.kind === 'no_children') expect(result.parentIssueId).toBe('PARENT'); + }); +}); + +describe('fetchSubIssueGraph — error shapes', () => { + test('non-2xx → error', async () => { + const fetchImpl = mockFetch({}, { ok: false, status: 503 }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('GraphQL errors → error', async () => { + const fetchImpl = mockFetch({ errors: [{ message: 'boom' }] }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('network throw → error (never throws)', async () => { + const fetchImpl = (async () => { throw new Error('ECONNRESET'); }) as unknown as typeof fetch; + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + test('missing issue in payload → error', async () => { + const fetchImpl = mockFetch({ data: { issue: null } }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + }); + + // An over-size epic must FAIL LOUD, not silently truncate: dropping children + // also drops their dependency edges, so the survivors release out of order. + test('children truncated (hasNextPage) → error, not a silently-cut graph', async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + pageInfo: { hasNextPage: true }, // Linear says there are more children + nodes: [{ id: 'A' }, { id: 'B' }], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + if (result.kind === 'error') expect(result.message).toMatch(/more than 100 sub-issues/i); + }); + + test("a child's blockers truncated (hasNextPage) → error (edges would be incomplete)", async () => { + const fetchImpl = mockFetch({ + data: { + issue: { + id: 'PARENT', + children: { + pageInfo: { hasNextPage: false }, + nodes: [{ + id: 'A', + identifier: 'ENG-1', + inverseRelations: { pageInfo: { hasNextPage: true }, nodes: [] }, + }], + }, + }, + }, + }); + const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl }); + expect(result.kind).toBe('error'); + if (result.kind === 'error') expect(result.message).toMatch(/blocking relations/i); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-base-branch.test.ts b/cdk/test/handlers/shared/orchestration-base-branch.test.ts new file mode 100644 index 000000000..58cc75b05 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-base-branch.test.ts @@ -0,0 +1,84 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { selectBaseBranch } from '../../../src/handlers/shared/orchestration-base-branch'; + +const pred = (sub_issue_id: string, branch_name: string) => ({ sub_issue_id, branch_name }); + +describe('selectBaseBranch', () => { + test('root (no predecessors) → default branch, no merges', () => { + expect(selectBaseBranch({ predecessors: [] })).toEqual({ + base_branch: 'main', merge_branches: [], shape: 'root', + }); + }); + + test('respects a custom default branch for roots', () => { + expect(selectBaseBranch({ predecessors: [], defaultBranch: 'develop' }).base_branch).toBe('develop'); + }); + + test('linear (1 predecessor) → stack on its branch, no merges', () => { + const sel = selectBaseBranch({ predecessors: [pred('A', 'bgagent/taskA/step-a')] }); + expect(sel).toEqual({ + base_branch: 'bgagent/taskA/step-a', merge_branches: [], shape: 'linear', + }); + }); + + test('diamond (2 predecessors) → base main + merge both branches', () => { + const sel = selectBaseBranch({ + predecessors: [pred('B', 'bgagent/taskB/b'), pred('C', 'bgagent/taskC/c')], + }); + expect(sel.shape).toBe('diamond'); + expect(sel.base_branch).toBe('main'); + expect(sel.merge_branches).toEqual(['bgagent/taskB/b', 'bgagent/taskC/c']); + }); + + test('diamond merge list is deduped and sorted (deterministic)', () => { + const sel = selectBaseBranch({ + predecessors: [pred('C', 'z-branch'), pred('B', 'a-branch'), pred('D', 'a-branch')], + }); + expect(sel.merge_branches).toEqual(['a-branch', 'z-branch']); + }); + + test('diamond uses default branch as base, not a predecessor', () => { + const sel = selectBaseBranch({ + predecessors: [pred('B', 'feat-b'), pred('C', 'feat-c')], defaultBranch: 'trunk', + }); + expect(sel.base_branch).toBe('trunk'); + }); + + test('predecessors missing a branch_name are ignored', () => { + // One real predecessor branch + one empty → degrades to linear on the real one. + const sel = selectBaseBranch({ predecessors: [pred('A', 'feat-a'), pred('B', '')] }); + expect(sel.shape).toBe('linear'); + expect(sel.base_branch).toBe('feat-a'); + }); + + test('all predecessors missing branches → degrade to root (never invalid base)', () => { + const sel = selectBaseBranch({ predecessors: [pred('A', ''), pred('B', '')] }); + expect(sel).toEqual({ base_branch: 'main', merge_branches: [], shape: 'root' }); + }); + + test('two predecessors that share a branch collapse to a single (linear) merge', () => { + // After dedup, only one distinct branch → treated as linear, not diamond. + const sel = selectBaseBranch({ predecessors: [pred('A', 'same'), pred('B', 'same')] }); + expect(sel.shape).toBe('linear'); + expect(sel.base_branch).toBe('same'); + expect(sel.merge_branches).toEqual([]); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel-factory.test.ts b/cdk/test/handlers/shared/orchestration-channel-factory.test.ts new file mode 100644 index 000000000..228c905d1 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel-factory.test.ts @@ -0,0 +1,116 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// The adapters resolve tokens on use, not on construction, so the factory can be +// exercised without any network — only the modules it selects between are stubbed +// out at the transport layer. +jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ + EMOJI_STARTED: 'eyes', + EMOJI_SUCCESS: 'white_check_mark', + EMOJI_FAILURE: 'x', + EMOJI_NEEDS_INPUT: 'question', +})); +jest.mock('../../../src/handlers/shared/jira-feedback', () => ({})); + +import { type Channel } from '../../../src/handlers/shared/orchestration-channel'; +import { + channelForSource, + registerChannelFactory, + registeredChannelSources, +} from '../../../src/handlers/shared/orchestration-channel-factory'; + +const BOTH = { linear: 'LinearRegistry', jira: 'JiraRegistry' }; + +describe('channelForSource', () => { + test('picks the adapter matching the stored channel', () => { + expect(channelForSource('linear', BOTH)?.kind).toBe('linear'); + expect(channelForSource('jira', BOTH)?.kind).toBe('jira'); + }); + + test('an orchestration with no recorded channel is treated as Linear', () => { + // Rows seeded before the field existed carry no source, and Linear was the + // only surface that could have seeded them — so defaulting keeps their + // feedback working rather than silencing it. + expect(channelForSource(undefined, BOTH)?.kind).toBe('linear'); + }); + + test('a surface whose registry is not configured yields no adapter', () => { + // Skipping is the safe answer: building another surface's adapter would + // address the wrong tenant. + expect(channelForSource('jira', { linear: 'LinearRegistry' })).toBeUndefined(); + expect(channelForSource('linear', { jira: 'JiraRegistry' })).toBeUndefined(); + expect(channelForSource(undefined, {})).toBeUndefined(); + }); + + test('a trigger channel with no issue-tracking surface yields no adapter', () => { + // 'api' / 'slack' submissions have no issue to post a panel on; the engine + // must skip rather than guess at a surface. + for (const source of ['api', 'webhook', 'slack', 'unknown-future-surface']) { + expect(channelForSource(source, BOTH)).toBeUndefined(); + } + }); + + test('a NEW surface can be added without editing this module or the interface', () => { + // The extensibility tenet: adopting ABCA for another tracker must not require + // patching the core. A closed union/switch would force every consumer to merge + // an upstream change for one more surface, so this asserts the registry is + // genuinely open — a surface registers itself and the lookup finds it. + const built: string[] = []; + const unregister = registerChannelFactory('acme-tracker', (table) => { + built.push(table); + return { kind: 'acme-tracker', postComment: jest.fn(), upsertComment: jest.fn(), reportFailure: jest.fn() } as unknown as Channel; + }); + try { + const ch = channelForSource('acme-tracker', { 'acme-tracker': 'AcmeRegistry' }); + expect(ch?.kind).toBe('acme-tracker'); + // The factory got its own credentials table, not another surface's. + expect(built).toEqual(['AcmeRegistry']); + expect(registeredChannelSources()).toContain('acme-tracker'); + } finally { + unregister(); + } + }); + + test('unregistering restores the prior state, so surfaces do not leak between callers', () => { + const unregister = registerChannelFactory('temp-surface', () => ({ kind: 'temp-surface' } as unknown as Channel)); + unregister(); + expect(registeredChannelSources()).not.toContain('temp-surface'); + expect(channelForSource('temp-surface', { 'temp-surface': 'T' })).toBeUndefined(); + }); + + test('replacing a registered surface is reversible', () => { + // A test (or a downstream override) may swap an adapter; restoring must put + // the original back rather than deleting the entry. + const unregister = registerChannelFactory('linear', () => ({ kind: 'stub-linear' } as unknown as Channel)); + expect(channelForSource('linear', BOTH)?.kind).toBe('stub-linear'); + unregister(); + expect(channelForSource('linear', BOTH)?.kind).toBe('linear'); + }); + + test('the returned adapter carries the registry it was given', async () => { + // A wrong registry would resolve a token for the wrong tenant, so prove the + // per-surface table actually reaches the adapter. + const linear = channelForSource('linear', BOTH); + expect(linear?.kind).toBe('linear'); + const jira = channelForSource('jira', BOTH); + expect(jira?.kind).toBe('jira'); + // Distinct adapters, not the same object handed back twice. + expect(linear).not.toBe(jira); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel-slack.test.ts b/cdk/test/handlers/shared/orchestration-channel-slack.test.ts new file mode 100644 index 000000000..edbd496ae --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel-slack.test.ts @@ -0,0 +1,213 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Mock at the Slack transport so the adapter's real mapping (channel op → Slack +// method + params) is what's asserted, with no network. +const slackFetchMock = jest.fn(); +const slackFetchTsMock = jest.fn(); +jest.mock('../../../src/handlers/shared/slack-api', () => ({ + slackFetch: (...a: unknown[]) => slackFetchMock(...a), + slackFetchTs: (...a: unknown[]) => slackFetchTsMock(...a), +})); + +const getSlackSecretMock = jest.fn(); +jest.mock('../../../src/handlers/shared/slack-verify', () => ({ + SLACK_SECRET_PREFIX: 'bgagent/slack/', + getSlackSecret: (...a: unknown[]) => getSlackSecretMock(...a), +})); + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +import { type IssueRef } from '../../../src/handlers/shared/orchestration-channel'; +import { makeSlackChannel, slackThreadRef } from '../../../src/handlers/shared/orchestration-channel-slack'; + +/** A Slack "issue" is a thread: channel + thread_ts, keyed by team_id. */ +const thread: IssueRef = { issueId: slackThreadRef('C123', '1700000000.001'), credentialsRef: 'T99' }; + +beforeEach(() => { + jest.clearAllMocks(); + getSlackSecretMock.mockResolvedValue('xoxb-token'); + slackFetchMock.mockResolvedValue(true); + slackFetchTsMock.mockResolvedValue('1700000000.002'); +}); + +describe('Slack channel adapter — the capability-gated surface', () => { + const ch = makeSlackChannel(); + + test('kind is slack', () => expect(ch.kind).toBe('slack')); + + test('OMITS the workflow-state ops, because Slack has no workflow state', () => { + // The point of the whole capability split: these must be ABSENT, not stubbed + // to a silent success. A no-op that returns true would tell the engine the + // platform mirrored a state it never moved. + expect('transitionState' in ch).toBe(false); + expect('revertState' in ch).toBe(false); + // Slack has no dependency model, so a DAG can't be read from it. + expect('fetchChildGraph' in ch).toBe(false); + // Bulk-deleting messages in a shared channel needs its own decision. + expect('sweepNotes' in ch).toBe(false); + }); + + test('still satisfies the required contract every surface must support', () => { + expect(typeof ch.postComment).toBe('function'); + expect(typeof ch.upsertComment).toBe('function'); + expect(typeof ch.reportFailure).toBe('function'); + }); + + test('resolves the bot token per WORKSPACE, from the ref credentials', () => { + // A wrong token would post into a different customer's Slack. + return ch.postComment(thread, 'hi').then(() => { + expect(getSlackSecretMock).toHaveBeenCalledWith('bgagent/slack/T99'); + }); + }); + + test('postComment posts INTO the thread and returns the new message ts', async () => { + const res = await ch.postComment(thread, 'hello'); + expect(res).toEqual({ commentId: '1700000000.002' }); + const [token, method, body] = slackFetchTsMock.mock.calls[0]; + expect(token).toBe('xoxb-token'); + expect(method).toBe('chat.postMessage'); + expect(body).toMatchObject({ channel: 'C123', thread_ts: '1700000000.001', text: 'hello' }); + }); + + test('upsertComment EDITS the given message in place — the maturing panel', async () => { + // Without edit-in-place a Slack epic would stream a new message per + // transition, which is the surface this design exists to avoid. + slackFetchTsMock.mockResolvedValue('1700000000.005'); + const res = await ch.upsertComment(thread, 'panel v2', { commentId: '1700000000.005' }); + expect(res).toEqual({ commentId: '1700000000.005' }); + const [, method, body] = slackFetchTsMock.mock.calls[0]; + expect(method).toBe('chat.update'); + expect(body).toMatchObject({ channel: 'C123', ts: '1700000000.005', text: 'panel v2' }); + expect(body).not.toHaveProperty('thread_ts'); // an edit targets a ts, not a thread + }); + + test('upsertComment with no existing ref posts a fresh in-thread message', async () => { + await ch.upsertComment(thread, 'panel v1'); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.postMessage'); + }); + + test('reactToComment ADDS without clearing anything', async () => { + await ch.reactToComment!({ commentId: '1700000000.009' }, thread, 'started'); + const calls = slackFetchMock.mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe('reactions.add'); + expect(calls[0][2]).toMatchObject({ channel: 'C123', timestamp: '1700000000.009', name: 'eyes' }); + }); + + test('replaceCommentReaction clears only its OWN prior markers, then adds the target', async () => { + // Slack has no atomic swap. Removing every emoji would strip a human's + // reaction; removing none would leave contradictory markers on a settled + // message. So: remove this adapter's own set, minus the target, then add. + await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded'); + const removes = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.remove'); + const adds = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add'); + expect(removes.map((c) => (c[2] as { name: string }).name).sort()) + .toEqual(['eyes', 'question', 'x']); // own markers, excluding the target + expect(adds).toHaveLength(1); + expect((adds[0][2] as { name: string }).name).toBe('white_check_mark'); + }); + + test('a swap that succeeds fully reports success', async () => { + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(true); + }); + + test('reports FAILURE when a stale marker could not be removed', async () => { + // Returning the add's result alone would claim the promised end state — one + // marker — while the message still shows "saw it" beside "done". That + // contradiction is what a caller checks this result to rule out. + slackFetchMock.mockImplementation((_token: string, method: string, body: { name: string }) => + Promise.resolve(!(method === 'reactions.remove' && body.name === 'eyes'))); + + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(false); + // The target is still attempted — a partial improvement beats leaving only + // the stale marker. + expect(slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add')).toHaveLength(1); + }); + + test('reports FAILURE when the target could not be added', async () => { + slackFetchMock.mockImplementation((_token: string, method: string) => + Promise.resolve(method !== 'reactions.add')); + + expect(await ch.replaceCommentReaction!({ commentId: '1700000000.009' }, thread, 'succeeded')).toBe(false); + }); + + test('the issue-level swap reports the same all-or-nothing result', async () => { + slackFetchMock.mockImplementation((_token: string, method: string, body: { name: string }) => + Promise.resolve(!(method === 'reactions.remove' && body.name === 'eyes'))); + + expect(await ch.replaceIssueReaction!(thread, 'failed')).toBe(false); + }); + + test('replaceIssueReaction marks the THREAD ROOT — the nearest thing to an issue', async () => { + await ch.replaceIssueReaction!(thread, 'failed'); + const adds = slackFetchMock.mock.calls.filter((c) => c[1] === 'reactions.add'); + expect((adds[0][2] as { timestamp: string }).timestamp).toBe('1700000000.001'); + expect((adds[0][2] as { name: string }).name).toBe('x'); + }); + + test('postThreadedReply replies under the parent message', async () => { + const res = await ch.postThreadedReply!(thread, { commentId: '1700000000.077' }, 'done'); + expect(res).toEqual({ commentId: '1700000000.002' }); + const [, method, body] = slackFetchTsMock.mock.calls[0]; + expect(method).toBe('chat.postMessage'); + expect(body).toMatchObject({ thread_ts: '1700000000.077', text: 'done' }); + }); + + test('upsertThreadedReply edits the existing reply, else creates one', async () => { + await ch.upsertThreadedReply!(thread, { commentId: 'p' }, 'settled', { commentId: '1700000000.088' }); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.update'); + slackFetchTsMock.mockClear(); + await ch.upsertThreadedReply!(thread, { commentId: 'p' }, 'settled'); + expect(slackFetchTsMock.mock.calls[0][1]).toBe('chat.postMessage'); + }); + + test('an uninstalled workspace skips cleanly instead of throwing', async () => { + // Feedback is advisory and must never gate the orchestration. + getSlackSecretMock.mockResolvedValue(null); + await expect(ch.postComment(thread, 'x')).resolves.toBeNull(); + await expect(ch.reportFailure(thread, 'x')).resolves.toBeUndefined(); + expect(slackFetchTsMock).not.toHaveBeenCalled(); + }); + + test('a malformed thread ref is refused rather than posting to a guessed channel', async () => { + // Posting into the wrong channel is worse than posting nowhere. + const bad: IssueRef = { issueId: 'no-separator', credentialsRef: 'T99' }; + await expect(ch.postComment(bad, 'x')).resolves.toBeNull(); + expect(slackFetchTsMock).not.toHaveBeenCalled(); + expect(getSlackSecretMock).not.toHaveBeenCalled(); + }); + + test('a failed Slack call reports null rather than a bogus ref', async () => { + slackFetchTsMock.mockResolvedValue(null); + await expect(ch.postComment(thread, 'x')).resolves.toBeNull(); + }); +}); + +describe('slackThreadRef', () => { + test('round-trips through the adapter that parses it', async () => { + // The seeding side and the adapter must agree on the ref format, or every + // Slack orchestration silently posts nowhere. + const ch = makeSlackChannel(); + await ch.postComment({ issueId: slackThreadRef('CABC', '123.456'), credentialsRef: 'T1' }, 'x'); + expect(slackFetchTsMock.mock.calls[0][2]).toMatchObject({ channel: 'CABC', thread_ts: '123.456' }); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-channel.test.ts b/cdk/test/handlers/shared/orchestration-channel.test.ts new file mode 100644 index 000000000..71c023b98 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-channel.test.ts @@ -0,0 +1,275 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Mock the per-surface feedback + graph helpers so the adapter tests exercise the +// mapping (channel op → surface call) without any network. +const linearPostIssueComment = jest.fn(); +const linearUpsertStatusComment = jest.fn(); +const linearReportIssueFailure = jest.fn(); +const linearAddCommentReaction = jest.fn(); +const linearSwapCommentReaction = jest.fn(); +const linearSwapIssueReaction = jest.fn(); +const linearTransitionIssueState = jest.fn(); +const linearRevertIssueToNotStarted = jest.fn(); +const linearReplyToComment = jest.fn(); +const linearUpsertThreadedReply = jest.fn(); +const linearSweepDecompositionNotes = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ + EMOJI_STARTED: 'eyes', + EMOJI_SUCCESS: 'white_check_mark', + EMOJI_FAILURE: 'x', + EMOJI_NEEDS_INPUT: 'question', + postIssueComment: (...a: unknown[]) => linearPostIssueComment(...a), + upsertStatusComment: (...a: unknown[]) => linearUpsertStatusComment(...a), + reportIssueFailure: (...a: unknown[]) => linearReportIssueFailure(...a), + reactToComment: (...a: unknown[]) => linearAddCommentReaction(...a), + swapCommentReaction: (...a: unknown[]) => linearSwapCommentReaction(...a), + swapIssueReaction: (...a: unknown[]) => linearSwapIssueReaction(...a), + transitionIssueState: (...a: unknown[]) => linearTransitionIssueState(...a), + revertIssueToNotStarted: (...a: unknown[]) => linearRevertIssueToNotStarted(...a), + replyToComment: (...a: unknown[]) => linearReplyToComment(...a), + upsertThreadedReply: (...a: unknown[]) => linearUpsertThreadedReply(...a), + sweepDecompositionNotes: (...a: unknown[]) => linearSweepDecompositionNotes(...a), +})); + +const resolveLinearOauthToken = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-oauth-resolver', () => ({ + resolveLinearOauthToken: (...a: unknown[]) => resolveLinearOauthToken(...a), +})); + +const fetchSubIssueGraph = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-subissue-fetch', () => ({ + fetchSubIssueGraph: (...a: unknown[]) => fetchSubIssueGraph(...a), +})); + +const jiraPostIssueComment = jest.fn(); +const jiraReportIssueFailure = jest.fn(); +jest.mock('../../../src/handlers/shared/jira-feedback', () => ({ + postIssueComment: (...a: unknown[]) => jiraPostIssueComment(...a), + reportIssueFailure: (...a: unknown[]) => jiraReportIssueFailure(...a), +})); + +import { type IssueRef } from '../../../src/handlers/shared/orchestration-channel'; +import { makeJiraChannel } from '../../../src/handlers/shared/orchestration-channel-jira'; +import { makeLinearChannel } from '../../../src/handlers/shared/orchestration-channel-linear'; + +const linearIssue: IssueRef = { issueId: 'lin-issue-1', credentialsRef: 'org-1', displayId: 'ENG-1' }; +const jiraIssue: IssueRef = { issueId: 'ABC-1', credentialsRef: 'cloud-1' }; + +beforeEach(() => jest.clearAllMocks()); + +describe('Linear channel adapter', () => { + const ch = makeLinearChannel('LinearRegistry'); + + test('kind is linear', () => expect(ch.kind).toBe('linear')); + + test('postComment builds the LinearFeedbackContext from the issue and returns ok', async () => { + linearPostIssueComment.mockResolvedValue({ ok: true }); + const res = await ch.postComment(linearIssue, 'hi'); + expect(res).not.toBeNull(); + const [ctx, id, body] = linearPostIssueComment.mock.calls[0]; + expect(ctx).toEqual({ linearWorkspaceId: 'org-1', registryTableName: 'LinearRegistry' }); + expect(id).toBe('lin-issue-1'); + expect(body).toBe('hi'); + }); + + test('upsertComment threads the existing comment id + returns the new id', async () => { + linearUpsertStatusComment.mockResolvedValue('cmt-99'); + const res = await ch.upsertComment(linearIssue, 'panel', { commentId: 'cmt-7' }); + expect(res).toEqual({ commentId: 'cmt-99' }); + expect(linearUpsertStatusComment.mock.calls[0][3]).toBe('cmt-7'); // existing id passed through + }); + + test('reactToComment ADDS a reaction without clearing existing ones', async () => { + // The receipt ack must not disturb other markers, so it maps to the + // add-only helper — never the swap helper, which deletes prior markers. + await ch.reactToComment!({ commentId: 'c1' }, linearIssue, 'started'); + expect(linearAddCommentReaction.mock.calls[0][2]).toBe('eyes'); + expect(linearSwapCommentReaction).not.toHaveBeenCalled(); + }); + + test('replaceCommentReaction clears prior markers via the swap helper', async () => { + await ch.replaceCommentReaction!({ commentId: 'c1' }, linearIssue, 'succeeded'); + expect(linearSwapCommentReaction.mock.calls[0][1]).toBe('c1'); + expect(linearSwapCommentReaction.mock.calls[0][2]).toBe('white_check_mark'); + expect(linearAddCommentReaction).not.toHaveBeenCalled(); + }); + + test('replaceIssueReaction targets the ISSUE, not a comment', async () => { + await ch.replaceIssueReaction!(linearIssue, 'failed'); + expect(linearSwapIssueReaction.mock.calls[0][1]).toBe('lin-issue-1'); + expect(linearSwapIssueReaction.mock.calls[0][2]).toBe('x'); + expect(linearSwapCommentReaction).not.toHaveBeenCalled(); + }); + + test('transitionState distinguishes running from awaiting-review by state name', async () => { + // Linear types both as `started`, so the preferred NAME is what separates + // them; dropping it would collapse the two intents into one move. + await ch.transitionState!(linearIssue, 'started'); + expect(linearTransitionIssueState.mock.calls[0][2]).toBe('started'); + expect(linearTransitionIssueState.mock.calls[0][3]).toEqual(['In Progress']); + + await ch.transitionState!(linearIssue, 'in_review'); + expect(linearTransitionIssueState.mock.calls[1][2]).toBe('started'); + expect(linearTransitionIssueState.mock.calls[1][3]).toEqual(['In Review']); + + await ch.transitionState!(linearIssue, 'completed'); + expect(linearTransitionIssueState.mock.calls[2][2]).toBe('completed'); + }); + + test('transitionState only permits a same-category re-open when asked', async () => { + await ch.transitionState!(linearIssue, 'started'); + expect(linearTransitionIssueState.mock.calls[0][4]).toBe(false); + await ch.transitionState!(linearIssue, 'started', { allowRegression: true }); + expect(linearTransitionIssueState.mock.calls[1][4]).toBe(true); + }); + + test('revertState routes to the guarded backward move', async () => { + linearRevertIssueToNotStarted.mockResolvedValue(true); + expect(await ch.revertState!(linearIssue)).toBe(true); + expect(linearRevertIssueToNotStarted.mock.calls[0][1]).toBe('lin-issue-1'); + }); + + test('postThreadedReply carries both the issue and the parent comment', async () => { + // Linear rejects a reply that names only the parent comment, so the issue + // id must travel with it. + linearReplyToComment.mockResolvedValue('reply-1'); + const res = await ch.postThreadedReply!(linearIssue, { commentId: 'parent-1' }, 'done'); + expect(res).toEqual({ commentId: 'reply-1' }); + const [, issueId, parentId, body] = linearReplyToComment.mock.calls[0]; + expect(issueId).toBe('lin-issue-1'); + expect(parentId).toBe('parent-1'); + expect(body).toBe('done'); + }); + + test('postThreadedReply returns null when the reply fails', async () => { + linearReplyToComment.mockResolvedValue(null); + expect(await ch.postThreadedReply!(linearIssue, { commentId: 'p' }, 'x')).toBeNull(); + }); + + test('upsertThreadedReply edits the existing reply and preserves a preview link on request', async () => { + linearUpsertThreadedReply.mockResolvedValue('reply-9'); + const res = await ch.upsertThreadedReply!( + linearIssue, { commentId: 'parent-1' }, 'settled', { commentId: 'reply-9' }, + { preservePreview: true }, + ); + expect(res).toEqual({ commentId: 'reply-9' }); + const [, issueId, parentId, body, existingId, options] = linearUpsertThreadedReply.mock.calls[0]; + expect(issueId).toBe('lin-issue-1'); + expect(parentId).toBe('parent-1'); + expect(body).toBe('settled'); + expect(existingId).toBe('reply-9'); + // The adapter forwards EVERY convergence flag explicitly, so the surface + // helper never has to guess a default. + expect(options).toEqual({ preservePreview: true, skipIfSettled: false, repairIfOverwritten: false }); + }); + + test('upsertThreadedReply defaults to not preserving a preview link', async () => { + linearUpsertThreadedReply.mockResolvedValue('reply-2'); + await ch.upsertThreadedReply!(linearIssue, { commentId: 'p' }, 'body'); + expect(linearUpsertThreadedReply.mock.calls[0][4]).toBeUndefined(); + expect(linearUpsertThreadedReply.mock.calls[0][5]) + .toEqual({ preservePreview: false, skipIfSettled: false, repairIfOverwritten: false }); + }); + + test('sweepNotes passes the comment to keep through and returns the deleted count', async () => { + linearSweepDecompositionNotes.mockResolvedValue(3); + expect(await ch.sweepNotes!(linearIssue, { commentId: 'keep-1' })).toBe(3); + expect(linearSweepDecompositionNotes.mock.calls[0][1]).toBe('lin-issue-1'); + expect(linearSweepDecompositionNotes.mock.calls[0][2]).toBe('keep-1'); + }); + + test('sweepNotes with nothing to keep passes no keep id', async () => { + linearSweepDecompositionNotes.mockResolvedValue(0); + expect(await ch.sweepNotes!(linearIssue)).toBe(0); + expect(linearSweepDecompositionNotes.mock.calls[0][2]).toBeUndefined(); + }); + + test('fetchChildGraph maps blocks-derived depends_on to the neutral node shape', async () => { + resolveLinearOauthToken.mockResolvedValue({ accessToken: 'tok' }); + fetchSubIssueGraph.mockResolvedValue({ + kind: 'ok', + parentIssueId: 'lin-issue-1', + children: [ + { id: 'a', identifier: 'ENG-2', title: 'A', depends_on: [] }, + { id: 'b', identifier: 'ENG-3', title: 'B', depends_on: ['a'] }, + ], + }); + const nodes = await ch.fetchChildGraph!(linearIssue); + expect(nodes).toEqual([ + { issueId: 'a', displayId: 'ENG-2', title: 'A', dependsOn: [] }, + { issueId: 'b', displayId: 'ENG-3', title: 'B', dependsOn: ['a'] }, + ]); + }); + + test('fetchChildGraph returns [] when the graph is unavailable (best-effort)', async () => { + resolveLinearOauthToken.mockResolvedValue({ accessToken: 'tok' }); + fetchSubIssueGraph.mockResolvedValue({ kind: 'error', message: 'boom' }); + expect(await ch.fetchChildGraph!(linearIssue)).toEqual([]); + }); +}); + +describe('Jira channel adapter (capability-limited surface)', () => { + const ch = makeJiraChannel('JiraRegistry'); + + test('kind is jira', () => expect(ch.kind).toBe('jira')); + + test('postComment builds the JiraFeedbackContext (cloudId) from the issue', async () => { + jiraPostIssueComment.mockResolvedValue(true); + const res = await ch.postComment(jiraIssue, 'hello'); + expect(res).not.toBeNull(); + const [ctx, id, body] = jiraPostIssueComment.mock.calls[0]; + expect(ctx).toEqual({ cloudId: 'cloud-1', registryTableName: 'JiraRegistry' }); + expect(id).toBe('ABC-1'); + expect(body).toBe('hello'); + }); + + test('reportFailure routes to the Jira failure helper', async () => { + await ch.reportFailure(jiraIssue, '❌ nope'); + expect(jiraReportIssueFailure).toHaveBeenCalledWith( + { cloudId: 'cloud-1', registryTableName: 'JiraRegistry' }, 'ABC-1', '❌ nope', + ); + }); + + test('OMITS every optional capability Jira lacks — the engine no-ops them', () => { + // Check presence via the key, not the bound method, so the engine's + // `if (channel.reactToComment)` capability guard is what's exercised. + for (const capability of [ + 'reactToComment', + 'replaceCommentReaction', + 'replaceIssueReaction', + 'transitionState', + 'revertState', + 'postThreadedReply', + 'upsertThreadedReply', + 'sweepNotes', + 'fetchChildGraph', + ]) { + expect(capability in ch).toBe(false); + } + }); + + test('still satisfies the required feedback contract every surface must support', () => { + // The point of the capability split: omitting the optional ops must not make + // Jira an incomplete Channel. + expect(typeof ch.postComment).toBe('function'); + expect(typeof ch.upsertComment).toBe('function'); + expect(typeof ch.reportFailure).toBe('function'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts new file mode 100644 index 000000000..a0196a409 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts @@ -0,0 +1,210 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + buildIterationInstruction, + detectNearMissMention, + isBotAuthoredComment, + KNOWN_EPIC_COMMANDS, + parseCommentTrigger, + parseRetryIntent, +} from '../../../src/handlers/shared/orchestration-comment-trigger'; + +describe('parseCommentTrigger', () => { + test('mention with instruction → triggered, instruction stripped + trimmed', () => { + const t = parseCommentTrigger('@bgagent the session timeout should be 30 min, not 60'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('the session timeout should be 30 min, not 60'); + }); + + test('mention mid-sentence still triggers', () => { + const t = parseCommentTrigger('Hey @bgagent please add a dark-mode toggle'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('Hey please add a dark-mode toggle'); + }); + + test('case-insensitive', () => { + expect(parseCommentTrigger('@BgAgent fix it').triggered).toBe(true); + }); + + test('bare mention with no text → triggered with empty instruction', () => { + const t = parseCommentTrigger('@bgagent'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe(''); + }); + + test('no mention → not triggered (ordinary human discussion)', () => { + expect(parseCommentTrigger('I think this looks good, merging soon').triggered).toBe(false); + }); + + test('empty / null / undefined body → not triggered', () => { + expect(parseCommentTrigger('').triggered).toBe(false); + expect(parseCommentTrigger(null).triggered).toBe(false); + expect(parseCommentTrigger(undefined).triggered).toBe(false); + }); + + test('the agent\'s own progress comment (no mention) never triggers', () => { + expect(parseCommentTrigger('🤖 Starting on the task — cloning repo now.').triggered).toBe(false); + expect(parseCommentTrigger('✅ PR opened: https://github.com/o/r/pull/5').triggered).toBe(false); + }); + + test('token boundary: @bgagentbot and email-like do NOT trigger', () => { + expect(parseCommentTrigger('ping @bgagentbot for help').triggered).toBe(false); + expect(parseCommentTrigger('email me at foo@bgagent.io').triggered).toBe(false); + }); + + test('multiple mentions are all stripped', () => { + const t = parseCommentTrigger('@bgagent do X and @bgagent also Y'); + expect(t.triggered).toBe(true); + expect(t.instruction).toBe('do X and also Y'); + }); + + // The bot's OWN comments must never re-trigger it, even when (especially + // when) they contain a literal @bgagent — otherwise it replies to itself + // forever. + describe('self-comment guard — the bot never triggers on its own comments', () => { + test('the disambiguation reply does NOT trigger, even though it embeds an "@bgagent <issue>:" example', () => { + // This EXACT body once spammed ~50 replies: it starts with 👋 and contains + // a literal @bgagent example, which an earlier regex re-matched → loop. + const body = '👋 I couldn\'t tell which sub-issue that\'s about.\n\nOtherwise, comment on the ' + + 'specific sub-issue, or name it here — e.g. `@bgagent ABCA-123: <what to change>`. The sub-issues are:'; + expect(parseCommentTrigger(body).triggered).toBe(false); + expect(isBotAuthoredComment(body)).toBe(true); + }); + + test('all bot template prefixes are recognized as bot-authored (never trigger)', () => { + for (const body of [ + '👋 That could apply to more than one sub-issue…', + '✅ Updated — PR #193.', + '✅ **ABCA orchestration complete**', + '❌ I made the change, but the build/tests didn\'t pass.', + '⚠️ **ABCA orchestration finished with failures**', + '🔄 **ABCA orchestration** · 1/3 complete', + '🤖 Starting on this issue…', + '🖼️ **Preview screenshot**', + '🔗 PR opened: https://github.com/o/r/pull/9', + ]) { + expect(isBotAuthoredComment(body)).toBe(true); + expect(parseCommentTrigger(body).triggered).toBe(false); + } + }); + + test('a genuine human @bgagent comment is NOT misclassified as bot-authored', () => { + expect(isBotAuthoredComment('@bgagent for the footer change the tagline')).toBe(false); + expect(parseCommentTrigger('@bgagent for the footer change the tagline').triggered).toBe(true); + }); + + test('leading whitespace before a bot marker is still caught', () => { + expect(isBotAuthoredComment(' \n✅ Updated — PR #193.')).toBe(true); + }); + }); +}); + +describe('buildIterationInstruction', () => { + test('uses the comment instruction when present', () => { + expect(buildIterationInstruction({ triggered: true, instruction: 'make the header sticky' })) + .toBe('make the header sticky'); + }); + + test('falls back to a generic directive for a bare mention', () => { + expect(buildIterationInstruction({ triggered: true, instruction: '' })) + .toMatch(/latest review feedback/i); + }); +}); + +describe('detectNearMissMention — a wrong-handle mention gets a nudge, not silence', () => { + test('@abca (label-name confusion) is a near-miss → nudge', () => { + expect(detectNearMissMention('@abca approve')).toBe(true); + expect(detectNearMissMention('hey @abca can you make it 2 tasks')).toBe(true); + // …even with a :suffix the reviewer copied from the label. + expect(detectNearMissMention('@abca:decompose please')).toBe(true); + }); + + test('a boundary-miss @bgagent handle is a near-miss (parseCommentTrigger deliberately skips it)', () => { + // parseCommentTrigger's `@bgagent(?![\w.])` does NOT trigger on these … + expect(parseCommentTrigger('ping @bgagentbot for help').triggered).toBe(false); + // … so detectNearMissMention catches them for the nudge instead of a silent drop. + expect(detectNearMissMention('ping @bgagentbot for help')).toBe(true); + expect(detectNearMissMention('@bgagentx approve')).toBe(true); + }); + + test('spelled-out / hyphenated variants are near-misses', () => { + expect(detectNearMissMention('@bg-agent approve')).toBe(true); + expect(detectNearMissMention('@bg_agent approve')).toBe(true); + expect(detectNearMissMention('@background-agent approve')).toBe(true); + expect(detectNearMissMention('@bgbot approve')).toBe(true); + }); + + test('the CORRECT @bgagent handle is NOT a near-miss (it triggers normally)', () => { + // A real trigger never reaches the near-miss branch, but assert it here too: + // the exact token must not be flagged as a wrong handle. + expect(detectNearMissMention('@bgagent approve')).toBe(false); + expect(detectNearMissMention('@bgagent make it 2 tasks')).toBe(false); + }); + + test('an email-like foo@bgagent.io is NOT a near-miss (not a mention at all)', () => { + expect(detectNearMissMention('email me at foo@bgagent.io')).toBe(false); + }); + + test('ordinary human discussion with no bot handle → not a near-miss', () => { + expect(detectNearMissMention('this looks good, merging soon')).toBe(false); + expect(detectNearMissMention('cc @teammate can you review')).toBe(false); + expect(detectNearMissMention('')).toBe(false); + expect(detectNearMissMention(null)).toBe(false); + expect(detectNearMissMention(undefined)).toBe(false); + }); + + test("the bot's own comments are never near-misses (no self-nudge loop)", () => { + // The wrong-mention nudge is 👋-prefixed → bot-authored → must not re-detect. + expect(detectNearMissMention('👋 I answer to `@bgagent` — I don\'t pick up other @-names')).toBe(false); + // A plan comment embeds a literal "@bgagent approve" example — still not a near-miss. + expect(detectNearMissMention('🗂️ Proposed breakdown … reply `@bgagent approve`')).toBe(false); + }); +}); + +describe('parseRetryIntent — recognise a plain "retry" command', () => { + test('bare retry phrases → true', () => { + for (const s of ['retry', 'Retry', 're-run', 'rerun', 'try again', 'run again', 'run it again', 'retry please']) { + expect(parseRetryIntent(s)).toBe(true); + } + }); + + test('a retry word leading a SUBSTANTIVE edit is NOT a bare retry (routes to iterate/revise)', () => { + // "retry the footer but change the color and spacing too" is an edit request, + // not a bare retry — must fall through so it isn't swallowed by the retry path. + expect(parseRetryIntent('retry the footer but change the color and spacing and margins')).toBe(false); + }); + + test('empty / non-retry instructions → false', () => { + expect(parseRetryIntent('')).toBe(false); + expect(parseRetryIntent('looks good, ship it')).toBe(false); + expect(parseRetryIntent('change the header color')).toBe(false); + // A bare @bgagent (empty instruction) is "address latest review", never a retry. + expect(parseRetryIntent(' ')).toBe(false); + }); + + test('markdown emphasis around the word is tolerated', () => { + expect(parseRetryIntent('`retry`')).toBe(true); + expect(parseRetryIntent('**retry**')).toBe(true); + }); + + test('KNOWN_EPIC_COMMANDS lists retry (kept in sync with the parser + panel copy)', () => { + expect(KNOWN_EPIC_COMMANDS).toContain('retry'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-dag.test.ts b/cdk/test/handlers/shared/orchestration-dag.test.ts new file mode 100644 index 000000000..4ef2ec4e7 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-dag.test.ts @@ -0,0 +1,148 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { validateDag, topologicalOrder, type DagNode } from '../../../src/handlers/shared/orchestration-dag'; + +const node = (id: string, ...depends_on: string[]): DagNode => ({ id, depends_on }); + +describe('validateDag — valid graphs', () => { + test('empty graph is valid with no layers', () => { + const result = validateDag([]); + expect(result).toEqual({ ok: true, layers: [] }); + }); + + test('single root node → one layer', () => { + const result = validateDag([node('A')]); + expect(result).toEqual({ ok: true, layers: [['A']] }); + }); + + test('independent siblings all land in layer 0', () => { + const result = validateDag([node('A'), node('B'), node('C')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A', 'B', 'C']]); + }); + + test('linear chain A→B→C produces three single-node layers', () => { + // B depends on A, C depends on B. + const result = validateDag([node('C', 'B'), node('B', 'A'), node('A')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B'], ['C']]); + }); + + test('diamond A→{B,C}→D layers B and C together, D last', () => { + const result = validateDag([ + node('A'), + node('B', 'A'), + node('C', 'A'), + node('D', 'B', 'C'), + ]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B', 'C'], ['D']]); + }); + + test('layers are sorted for deterministic output', () => { + const result = validateDag([node('z'), node('a'), node('m')]); + if (result.ok) expect(result.layers[0]).toEqual(['a', 'm', 'z']); + }); + + test('tolerates a duplicated edge to the same predecessor', () => { + // depends_on lists A twice — should not double-count in-degree. + const result = validateDag([node('A'), { id: 'B', depends_on: ['A', 'A'] }]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.layers).toEqual([['A'], ['B']]); + }); +}); + +describe('validateDag — rejected graphs', () => { + test('self-loop is a cycle', () => { + const result = validateDag([node('A', 'A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['A']); + } + }); + + test('two-node cycle A↔B', () => { + const result = validateDag([node('A', 'B'), node('B', 'A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['A', 'B']); + } + }); + + test('cycle is reported even when valid roots exist', () => { + // R is a clean root; X→Y→Z→X is a cycle hanging off nothing. + const result = validateDag([ + node('R'), + node('X', 'Z'), + node('Y', 'X'), + node('Z', 'Y'), + ]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('cycle'); + expect(result.offendingIds).toEqual(['X', 'Y', 'Z']); + } + }); + + test('dangling edge → depends_on points outside the node set', () => { + const result = validateDag([node('A'), node('B', 'GHOST')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('dangling_edge'); + expect(result.offendingIds).toEqual(['B']); + } + }); + + test('duplicate id', () => { + const result = validateDag([node('A'), node('A')]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('duplicate_id'); + expect(result.offendingIds).toEqual(['A']); + } + }); + + test('duplicate-id check precedes dangling/cycle checks', () => { + // Duplicate A plus a dangling edge — duplicate wins (checked first). + const result = validateDag([node('A'), node('A', 'GHOST')]); + if (!result.ok) expect(result.reason).toBe('duplicate_id'); + }); + + test('rejection carries a user-facing message', () => { + const result = validateDag([node('A', 'B'), node('B', 'A')]); + if (!result.ok) { + expect(result.message).toMatch(/cycle/i); + expect(result.message.length).toBeGreaterThan(0); + } + }); +}); + +describe('topologicalOrder', () => { + test('returns a flat valid order for an accepted graph', () => { + const order = topologicalOrder([node('C', 'B'), node('B', 'A'), node('A')]); + expect(order).toEqual(['A', 'B', 'C']); + }); + + test('throws on an invalid graph', () => { + expect(() => topologicalOrder([node('A', 'B'), node('B', 'A')])).toThrow(/invalid dependency graph/i); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-epic-tip.test.ts b/cdk/test/handlers/shared/orchestration-epic-tip.test.ts new file mode 100644 index 000000000..c1c100c0a --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-epic-tip.test.ts @@ -0,0 +1,66 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { resolveEpicTip, type TipCandidate } from '../../../src/handlers/shared/orchestration-epic-tip'; + +const node = (id: string, depends_on: string[] = [], created_at = '2026-01-01'): TipCandidate => + ({ sub_issue_id: id, depends_on, created_at }); + +describe('resolveEpicTip — where a new node with no declared dependency stacks', () => { + test('empty epic → no tip (degrade to a root node off the default branch)', () => { + expect(resolveEpicTip([])).toEqual([]); + }); + + test('linear chain A→B→C → tip is the single leaf C', () => { + const epic = [node('A'), node('B', ['A']), node('C', ['B'])]; + expect(resolveEpicTip(epic)).toEqual(['C']); + }); + + test('single node epic → that node is the tip', () => { + expect(resolveEpicTip([node('A')])).toEqual(['A']); + }); + + test('fan-out (two independent leaves) → diamond: both leaves, sorted', () => { + // root R; B and C both depend on R, nothing depends on B or C. + const epic = [node('R'), node('B', ['R']), node('C', ['R'])]; + expect(resolveEpicTip(epic)).toEqual(['B', 'C']); + }); + + test('integration node present → it IS the combined tip (stack on it alone, no redundant diamond)', () => { + // A and B are leaves; the integration node depends on both, so it is the + // single most-downstream node. A new node stacks on integration only. + const epic = [ + node('A'), + node('B'), + node('orch_x__integration', ['A', 'B']), + ]; + expect(resolveEpicTip(epic)).toEqual(['orch_x__integration']); + }); + + test('multiple roots, one chain → only the genuine leaf is the tip', () => { + // A→B (B is a leaf); D is a standalone leaf. Two leaves → diamond. + const epic = [node('A'), node('B', ['A']), node('D')]; + expect(resolveEpicTip(epic)).toEqual(['B', 'D']); + }); + + test('deterministic ordering regardless of input order', () => { + const epic = [node('C', ['R']), node('R'), node('B', ['R'])]; + expect(resolveEpicTip(epic)).toEqual(['B', 'C']); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-graph-source.test.ts b/cdk/test/handlers/shared/orchestration-graph-source.test.ts new file mode 100644 index 000000000..4ae531688 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-graph-source.test.ts @@ -0,0 +1,104 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +import { + declarativeGraphSource, + linearGraphSource, +} from '../../../src/handlers/shared/orchestration-graph-source'; + +/** A `fetch` impl returning a Linear children payload, for the Linear source. */ +function linearFetch(children: Array<{ id: string; blockedBy?: string[] }>): typeof fetch { + return (async () => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + issue: { + id: 'PARENT', + children: { + nodes: children.map((c) => ({ + id: c.id, + inverseRelations: { nodes: (c.blockedBy ?? []).map((b) => ({ type: 'blocks', issue: { id: b } })) }, + })), + }, + }, + }, + }), + })) as unknown as typeof fetch; +} + +describe('declarativeGraphSource', () => { + test('non-empty node list → ok with the same children', async () => { + const nodes = [ + { id: 'a', depends_on: [], title: 'A' }, + { id: 'b', depends_on: ['a'], title: 'B' }, + ]; + const result = await declarativeGraphSource(nodes)(); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') expect(result.children).toEqual(nodes); + }); + + test('empty node list → no_children (caller falls through to single task)', async () => { + const result = await declarativeGraphSource([])(); + expect(result.kind).toBe('no_children'); + }); + + test('never errors — validity is enforced downstream, not here', async () => { + // A cyclic graph is still "ok" from the source's perspective; validateDag + // (in discoverOrchestration) is what rejects it. + const result = await declarativeGraphSource([ + { id: 'x', depends_on: ['y'] }, + { id: 'y', depends_on: ['x'] }, + ])(); + expect(result.kind).toBe('ok'); + }); +}); + +describe('linearGraphSource', () => { + test('maps a Linear children payload to ok', async () => { + const result = await linearGraphSource('tok', 'PARENT', { + fetchImpl: linearFetch([{ id: 'A' }, { id: 'B', blockedBy: ['A'] }]), + })(); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.children.map((c) => c.id)).toEqual(['A', 'B']); + expect(result.children[1].depends_on).toEqual(['A']); + } + }); + + test('no children → no_children', async () => { + const empty = (async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { issue: { id: 'PARENT', children: { nodes: [] } } } }), + })) as unknown as typeof fetch; + const result = await linearGraphSource('tok', 'PARENT', { fetchImpl: empty })(); + expect(result.kind).toBe('no_children'); + }); + + test('Linear API failure → error (not silently empty)', async () => { + const fail = (async () => ({ ok: false, status: 500, json: async () => ({}) })) as unknown as typeof fetch; + const result = await linearGraphSource('tok', 'PARENT', { fetchImpl: fail })(); + expect(result.kind).toBe('error'); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-integration-node.test.ts b/cdk/test/handlers/shared/orchestration-integration-node.test.ts new file mode 100644 index 000000000..2cc011290 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-integration-node.test.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { SubIssueNode } from '../../../src/handlers/shared/linear-subissue-fetch'; +import { + computeLeaves, + INTEGRATION_NODE_SUFFIX, + isIntegrationNode, + withIntegrationNode, +} from '../../../src/handlers/shared/orchestration-integration-node'; + +const n = (id: string, deps: string[] = []): SubIssueNode => ({ id, depends_on: deps }); +const ORCH = 'orch_abc123'; + +describe('computeLeaves', () => { + test('linear chain A→B→C → only C is a leaf', () => { + expect(computeLeaves([n('A'), n('B', ['A']), n('C', ['B'])])).toEqual(['C']); + }); + + test('pure fan-out A→{B,C} → B and C are leaves (A is not)', () => { + expect([...computeLeaves([n('A'), n('B', ['A']), n('C', ['A'])])].sort()).toEqual(['B', 'C']); + }); + + test('diamond A→{B,C}→D → only D is a leaf', () => { + expect(computeLeaves([n('A'), n('B', ['A']), n('C', ['A']), n('D', ['B', 'C'])])).toEqual(['D']); + }); + + test('all independent roots → all are leaves', () => { + expect([...computeLeaves([n('A'), n('B'), n('C')])].sort()).toEqual(['A', 'B', 'C']); + }); +}); + +describe('withIntegrationNode', () => { + test('linear chain (1 leaf) → unchanged, not added', () => { + const r = withIntegrationNode([n('A'), n('B', ['A'])], ORCH); + expect(r.added).toBe(false); + expect(r.nodes).toHaveLength(2); + }); + + test('explicit diamond (1 leaf D) → unchanged, not added', () => { + const r = withIntegrationNode([n('A'), n('B', ['A']), n('C', ['A']), n('D', ['B', 'C'])], ORCH); + expect(r.added).toBe(false); + }); + + test('pure fan-out (>1 leaf) → appends a synthetic node over all leaves', () => { + const r = withIntegrationNode([n('A'), n('B', ['A']), n('C', ['A'])], ORCH); + expect(r.added).toBe(true); + expect(r.nodes).toHaveLength(4); + const integ = r.nodes[r.nodes.length - 1]; + expect(integ.id).toBe(`${ORCH}${INTEGRATION_NODE_SUFFIX}`); + expect([...integ.depends_on].sort()).toEqual(['B', 'C']); + expect(integ.title).toContain('Integration'); + expect(integ.identifier).toBeUndefined(); + }); + + test('three independent roots → integration node depends on all three', () => { + const r = withIntegrationNode([n('A'), n('B'), n('C')], ORCH); + expect(r.added).toBe(true); + expect([...r.nodes[r.nodes.length - 1].depends_on].sort()).toEqual(['A', 'B', 'C']); + }); + + test('synthetic node id is idempotency-key safe (no "#", matches /^[A-Za-z0-9_-]+$/)', () => { + const r = withIntegrationNode([n('A'), n('B')], ORCH); + const id = r.nodes[r.nodes.length - 1].id; + // releaseChild builds `${orch}_${sub}` and createTaskCore validates it. + expect(`${ORCH}_${id}`).toMatch(/^[a-zA-Z0-9_-]{1,128}$/); + }); +}); + +describe('isIntegrationNode', () => { + test('true for the synthetic suffix, false for real ids', () => { + expect(isIntegrationNode(`${ORCH}${INTEGRATION_NODE_SUFFIX}`)).toBe(true); + expect(isIntegrationNode('a1b2c3-uuid')).toBe(false); + expect(isIntegrationNode('#meta')).toBe(false); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-store.test.ts b/cdk/test/handlers/shared/orchestration-store.test.ts new file mode 100644 index 000000000..eb5b02158 --- /dev/null +++ b/cdk/test/handlers/shared/orchestration-store.test.ts @@ -0,0 +1,1041 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { GetCommand, BatchWriteCommand, UpdateCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { SubIssueNode } from '../../../src/handlers/shared/linear-subissue-fetch'; +import { + seedOrchestration, + setRetryCommentId, + extendOrchestration, + deriveOrchestrationId, + OrchestrationIdCollisionError, + claimRollup, + clearRollupClaim, + claimCommentAck, + loadOrchestration, + findOrchestrationChildByBranch, +} from '../../../src/handlers/shared/orchestration-store'; + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const child = (id: string, depends_on: string[] = [], extra: Partial<SubIssueNode> = {}): SubIssueNode => ({ + id, + depends_on, + ...extra, +}); + +interface MockDdb { + send: jest.Mock; +} + +function makeDdb(): MockDdb { + return { send: jest.fn() }; +} + +const TABLE = 'OrchestrationTable'; +const NOW = '2026-06-09T12:00:00.000Z'; +const RC = { platform_user_id: 'platform-user-1' }; + +describe('deriveOrchestrationId', () => { + test('is deterministic for the same parent id', () => { + expect(deriveOrchestrationId('ISSUE-123')).toBe(deriveOrchestrationId('ISSUE-123')); + }); + + test('differs for different parent ids', () => { + expect(deriveOrchestrationId('A')).not.toBe(deriveOrchestrationId('B')); + }); + + test('is prefixed and fixed-length', () => { + const id = deriveOrchestrationId('anything'); + expect(id).toMatch(/^orch_[0-9a-f]{32}$/); + }); +}); + +describe('seedOrchestration — first write', () => { + test('writes one row per child plus a meta row', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce({ Item: undefined }) // GetCommand: no existing meta + .mockResolvedValueOnce({}); // BatchWrite + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(false); + // 2 children + 1 meta row. + expect(result.rowsWritten).toBe(3); + expect(result.orchestrationId).toBe(deriveOrchestrationId('PARENT')); + + // First call is the idempotency GetCommand. + expect(ddb.send.mock.calls[0][0]).toBeInstanceOf(GetCommand); + // Second is the BatchWrite. + const batch = ddb.send.mock.calls[1][0]; + expect(batch).toBeInstanceOf(BatchWriteCommand); + const puts = batch.input.RequestItems[TABLE]; + expect(puts).toHaveLength(3); + }); + + test('roots get child_status=ready, blocked children get blocked', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const byId = Object.fromEntries(puts.map((p) => [p.PutRequest.Item.sub_issue_id, p.PutRequest.Item])); + expect(byId.A.child_status).toBe('ready'); + expect(byId.B.child_status).toBe('blocked'); + expect(byId.B.depends_on).toEqual(['A']); + }); + + test('persists linear_identifier and title when present', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A', [], { identifier: 'ENG-1', title: 'Do thing' })], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const a = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'A')!.PutRequest.Item; + expect(a.linear_identifier).toBe('ENG-1'); + expect(a.title).toBe('Do thing'); + }); + + test('persists the planner description onto the child row (and omits an empty one)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [ + child('A', [], { title: 'Dashboard', description: 'Create `dashboard.html` at the root.' }), + child('B', [], { title: 'No scope' }), // no description + ], + now: NOW, + releaseContext: RC, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const a = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'A')!.PutRequest.Item; + const b = puts.find((p) => p.PutRequest.Item.sub_issue_id === 'B')!.PutRequest.Item; + expect(a.description).toBe('Create `dashboard.html` at the root.'); + expect(b).not.toHaveProperty('description'); // absent, not an empty string + }); + + test('chunks BatchWrite into groups of 25', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValue({}); // Get + all batches + ddb.send.mockResolvedValueOnce({ Item: undefined }); // first call = Get + + // 30 children + 1 meta = 31 rows → 2 batches (25 + 6). + const children = Array.from({ length: 30 }, (_, i) => child(`C${i}`)); + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children, + now: NOW, + releaseContext: RC, + }); + + expect(result.rowsWritten).toBe(31); + // 1 Get + 2 BatchWrite = 3 sends. + expect(ddb.send).toHaveBeenCalledTimes(3); + }); + + test('includes ttl on rows when provided', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + ttl: 9999999999, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + expect(puts.every((p) => p.PutRequest.Item.ttl === 9999999999)).toBe(true); + }); + + test('persists channel_source on the meta row when supplied (so feedback goes back to the right surface)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: { platform_user_id: 'u1', channel_source: 'linear' }, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const meta = puts.find((p) => p.PutRequest.Item.sub_issue_id === '#meta')!.PutRequest.Item; + expect(meta.channel_source).toBe('linear'); + }); + + test('omits channel_source from the meta row when not supplied (back-compat)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, // no channel_source + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record<string, unknown> } }>; + const meta = puts.find((p) => p.PutRequest.Item.sub_issue_id === '#meta')!.PutRequest.Item; + expect(meta.channel_source).toBeUndefined(); + }); +}); + +describe('seedOrchestration — idempotent replay', () => { + test('skips writing when a meta row already exists', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: { orchestration_id: 'x', sub_issue_id: '#meta' } }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A'), child('B', ['A'])], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + expect(result.rowsWritten).toBe(0); + // Only the Get fired — no BatchWrite. + expect(ddb.send).toHaveBeenCalledTimes(1); + expect(ddb.send.mock.calls[0][0]).toBeInstanceOf(GetCommand); + }); + + test('a row that records the SAME owner is a replay, not a collision', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'WS', + }, + }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + }); + + test('recognises the owner under the LEGACY attribute names too', async () => { + // A row written before the neutral names existed still records who owns it. + // Reading only the new names would see "no owner recorded" and wave a genuine + // cross-tenant collision through, so this fixture is a legacy row whose owner + // DIFFERS — the case that distinguishes reading both names from reading one. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_linear_issue_id: 'PARENT', + linear_workspace_id: 'OTHER-TENANT', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + }); + + test('a legacy row with MATCHING legacy owner fields still replays', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_linear_issue_id: 'PARENT', + linear_workspace_id: 'WS', + }, + }); + + const result = await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + }); + + expect(result.alreadyExisted).toBe(true); + }); + + test('REFUSES to adopt an orchestration owned by a different tenant', async () => { + // The id is derived from the parent ref alone, so a surface whose refs are + // only project-unique would let two tenants derive the same id — and the + // replay gate would hand the second one the first one's children, releasing + // work against the other tenant's repo under the other tenant's credentials. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'OTHER-TENANT', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + + // Nothing written — refusing must not half-seed. + expect(ddb.send).toHaveBeenCalledTimes(1); + }); + + test('REFUSES when the id is held by a different parent issue', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Item: { + orchestration_id: 'x', + sub_issue_id: '#meta', + parent_issue_ref: 'SOME-OTHER-EPIC', + credentials_ref: 'WS', + }, + }); + + await expect(seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'PARENT', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: RC, + })).rejects.toThrow(OrchestrationIdCollisionError); + }); +}); + +describe('claimRollup — exactly-once parent rollup', () => { + function makeDdb(): MockDdb { return { send: jest.fn() }; } + + test('first claim wins (conditional write succeeds) → true', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + const won = await claimRollup(ddb as never, TABLE, 'orch_1', NOW); + expect(won).toBe(true); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.ConditionExpression).toContain('attribute_not_exists(rollup_posted_at)'); + expect(cmd.input.Key).toMatchObject({ sub_issue_id: '#meta' }); + }); + + test('second claim loses (ConditionalCheckFailed) → false, no throw', async () => { + const ddb = makeDdb(); + const e = Object.assign(new Error('c'), { name: 'ConditionalCheckFailedException' }); + ddb.send.mockRejectedValueOnce(e); + const won = await claimRollup(ddb as never, TABLE, 'orch_1', NOW); + expect(won).toBe(false); + }); + + test('non-conditional error propagates', async () => { + const ddb = makeDdb(); + ddb.send.mockRejectedValueOnce(new Error('throttle')); + await expect(claimRollup(ddb as never, TABLE, 'orch_1', NOW)).rejects.toThrow('throttle'); + }); +}); + +describe('clearRollupClaim — release the claim so a re-completing epic re-settles', () => { + test('REMOVEs rollup_posted_at on the meta row (unconditional, idempotent)', async () => { + const ddb = { send: jest.fn().mockResolvedValueOnce({}) }; + await clearRollupClaim(ddb as never, TABLE, 'orch_1', NOW); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.UpdateExpression).toContain('REMOVE rollup_posted_at'); + expect(cmd.input.Key).toMatchObject({ sub_issue_id: '#meta', orchestration_id: 'orch_1' }); + // No conditional — a no-op when already absent. + expect(cmd.input.ConditionExpression).toBeUndefined(); + }); +}); + +describe('claimCommentAck — exactly-once per comment, so a redelivered webhook is deduped', () => { + test('first delivery wins → true, conditional create-once on a per-comment SK + TTL', async () => { + const ddb = { send: jest.fn().mockResolvedValueOnce({}) }; + const won = await claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000); + expect(won).toBe(true); + const cmd = ddb.send.mock.calls[0][0] as UpdateCommand; + expect(cmd).toBeInstanceOf(UpdateCommand); + expect(cmd.input.Key).toMatchObject({ orchestration_id: 'orch_1', sub_issue_id: 'ack#cmt-9' }); + expect(cmd.input.ConditionExpression).toContain('attribute_not_exists(orchestration_id)'); + expect(cmd.input.ExpressionAttributeValues).toMatchObject({ ':ttl': 1781800000 }); + // ``ttl`` is a DynamoDB reserved keyword — must be aliased, else the write + // 400s with ValidationException. Observed in practice: the unaliased form + // errored out the whole handler, silently dropping the comment. + expect(cmd.input.ExpressionAttributeNames).toMatchObject({ '#ttl': 'ttl' }); + expect(cmd.input.UpdateExpression).toContain('#ttl'); + }); + + test('redelivery of the same comment loses (ConditionalCheckFailed) → false, no throw', async () => { + const ddb = { send: jest.fn().mockRejectedValueOnce(Object.assign(new Error('c'), { name: 'ConditionalCheckFailedException' })) }; + expect(await claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000)).toBe(false); + }); + + test('non-conditional error propagates', async () => { + const ddb = { send: jest.fn().mockRejectedValueOnce(new Error('throttle')) }; + await expect(claimCommentAck(ddb as never, TABLE, 'orch_1', 'cmt-9', NOW, 1781800000)).rejects.toThrow('throttle'); + }); +}); + +describe('renamed row attributes stay readable across the rename', () => { + // Rows already in the table outlive the deploy that renames an attribute, so an + // epic mid-flight when the rename ships must still load and settle. These pin + // BOTH spellings, and the fact that a row carrying only the legacy names loads + // is the actual back-compat guarantee. + const child = (extra: Record<string, unknown>) => ({ + orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded', ...extra, + }); + + test('a row written BEFORE the rename (legacy names only) still loads', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_linear_issue_id: 'P-old', + linear_workspace_id: 'WS-old', + }, + child({ parent_linear_issue_id: 'P-old', linear_workspace_id: 'WS-old', linear_identifier: 'ENG-1' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-old'); + expect(snap!.meta.credentials_ref).toBe('WS-old'); + expect(snap!.children[0].parent_issue_ref).toBe('P-old'); + expect(snap!.children[0].credentials_ref).toBe('WS-old'); + expect(snap!.children[0].display_id).toBe('ENG-1'); + }); + + test('a row written AFTER the rename (neutral names only) loads too', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_issue_ref: 'P-new', + credentials_ref: 'WS-new', + }, + child({ parent_issue_ref: 'P-new', credentials_ref: 'WS-new', display_id: 'ENG-2' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-new'); + expect(snap!.meta.credentials_ref).toBe('WS-new'); + expect(snap!.children[0].parent_issue_ref).toBe('P-new'); + expect(snap!.children[0].credentials_ref).toBe('WS-new'); + expect(snap!.children[0].display_id).toBe('ENG-2'); + }); + + test('the neutral name wins when a row carries both', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + repo: 'o/r', + platform_user_id: 'u1', + child_count: 1, + parent_issue_ref: 'P-new', + parent_linear_issue_id: 'P-old', + credentials_ref: 'WS-new', + linear_workspace_id: 'WS-old', + }, + child({ parent_issue_ref: 'P-new', parent_linear_issue_id: 'P-old' }), + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap!.meta.parent_issue_ref).toBe('P-new'); + expect(snap!.meta.credentials_ref).toBe('WS-new'); + expect(snap!.children[0].parent_issue_ref).toBe('P-new'); + }); +}); + +describe('loadOrchestration — dedup marker rows are not children', () => { + test('excludes ack#<commentId> marker rows from children (only real sub-issues count)', async () => { + const ddb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 2 }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded' }, + { orchestration_id: 'orch_1', sub_issue_id: 'orch_1__integration', depends_on: ['uuid-A'], child_status: 'succeeded' }, + { orchestration_id: 'orch_1', sub_issue_id: 'ack#cmt-9', acked_at: NOW, ttl: 1781800000 }, // marker — must NOT be a child + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap).not.toBeNull(); + const ids = snap!.children.map((c) => c.sub_issue_id).sort(); + expect(ids).toEqual(['orch_1__integration', 'uuid-A']); // ack# row excluded; integration kept + }); + + test('round-trips pre_screened_attachments through the meta row', async () => { + const att = { + attachment_id: 'a1', + type: 'file', + content_type: 'application/pdf', + filename: 'spec.pdf', + s3_key: 'attachments/u1/epic-P/a1/spec.pdf', + s3_version_id: 'v1', + size_bytes: 42, + screening: { status: 'passed', screened_at: NOW }, + checksum_sha256: 'x'.repeat(64), + }; + // Seed writes the JSON string onto the meta row. + const seedDdb = makeDdb(); + seedDdb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + await seedOrchestration({ + ddb: seedDdb as never, + tableName: TABLE, + parentIssueRef: 'P', + credentialsRef: 'WS', + repo: 'o/r', + children: [child('A')], + now: NOW, + releaseContext: { platform_user_id: 'u1', pre_screened_attachments: [att as never] }, + }); + const batch = seedDdb.send.mock.calls[1][0]; + const metaPut = batch.input.RequestItems[TABLE].map((r: { PutRequest: { Item: Record<string, unknown> } }) => r.PutRequest.Item) + .find((i: Record<string, unknown>) => i.sub_issue_id === '#meta'); + expect(typeof metaPut.pre_screened_attachments_json).toBe('string'); + + // Load parses it back into release_context.pre_screened_attachments. + const loadDdb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 1, pre_screened_attachments_json: JSON.stringify([att]) }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'ready' }, + ], + }), + }; + const snap = await loadOrchestration(loadDdb as never, TABLE, 'orch_1'); + expect(snap!.meta.release_context.pre_screened_attachments).toHaveLength(1); + expect(snap!.meta.release_context.pre_screened_attachments![0].s3_key).toBe('attachments/u1/epic-P/a1/spec.pdf'); + }); + + test('a malformed pre_screened_attachments_json degrades to no attachments (best-effort)', async () => { + const loadDdb = { + send: jest.fn().mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 1, pre_screened_attachments_json: '{not json' }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'ready' }, + ], + }), + }; + const snap = await loadOrchestration(loadDdb as never, TABLE, 'orch_1'); + expect(snap).not.toBeNull(); + expect(snap!.meta.release_context.pre_screened_attachments).toBeUndefined(); + }); + + test('paginates a multi-page Query so a large epic is NOT truncated to one 1MB page', async () => { + // A single Query returns at most 1MB; a large epic (many children + ack# + // markers) would otherwise silently drop children → mis-settle/strand. Two + // pages: the first returns the meta + child A with a LastEvaluatedKey, the + // second returns child B and no key. Both children must appear. + const ddb = { + send: jest.fn() + .mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: '#meta', parent_linear_issue_id: 'P', linear_workspace_id: 'WS', repo: 'o/r', platform_user_id: 'u1', child_count: 2 }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded' }, + ], + LastEvaluatedKey: { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A' }, + }) + .mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-B', depends_on: ['uuid-A'], child_status: 'blocked' }, + ], + }), + }; + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(ddb.send).toHaveBeenCalledTimes(2); // followed LastEvaluatedKey + expect(snap).not.toBeNull(); + expect(snap!.children.map((c) => c.sub_issue_id).sort()).toEqual(['uuid-A', 'uuid-B']); + // 2nd Query carried ExclusiveStartKey from the 1st page's LastEvaluatedKey. + const secondCall = ddb.send.mock.calls[1][0] as QueryCommand; + expect((secondCall.input as { ExclusiveStartKey?: unknown }).ExclusiveStartKey).toEqual({ + orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', + }); + }); +}); + +describe('loadOrchestration — "rows but no meta" is only alarming with a real child', () => { + const loggerMock = jest.requireMock('../../../src/handlers/shared/logger').logger as { + info: jest.Mock; warn: jest.Mock; error: jest.Mock; + }; + beforeEach(() => { loggerMock.info.mockClear(); loggerMock.warn.mockClear(); }); + + // A never-decomposed issue still accumulates dedup MARKER rows (`ack#…`) under the + // same derived id, so this state is NORMAL for it — a plain `@bgagent` on any such + // issue reaches it. Warning there cried wolf on a healthy path. + test('marker rows only → info, not warn', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ orchestration_id: 'orch_1', sub_issue_id: 'ack#help', acked_at: NOW }], + }); + + expect(await loadOrchestration(ddb as never, TABLE, 'orch_1')).toBeNull(); + expect(loggerMock.info).toHaveBeenCalledWith( + expect.stringContaining('only dedup markers'), expect.anything(), + ); + expect(loggerMock.warn).not.toHaveBeenCalled(); + }); + + test('a REAL child row with no meta is the genuinely inconsistent case → warn', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [ + { orchestration_id: 'orch_1', sub_issue_id: 'ack#help', acked_at: NOW }, + { orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', child_status: 'ready', depends_on: [] }, + ], + }); + + expect(await loadOrchestration(ddb as never, TABLE, 'orch_1')).toBeNull(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.stringContaining('meta row is missing'), expect.anything(), + ); + }); +}); + +describe('setRetryCommentId — remember the retry comment awaiting an outcome', () => { + test('records the comment id on the meta row', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + + await setRetryCommentId(ddb as never, TABLE, 'orch_1', 'retry-cmt-1'); + + const cmd = ddb.send.mock.calls[0][0] as { input: Record<string, unknown> }; + expect(cmd.input.Key).toEqual({ orchestration_id: 'orch_1', sub_issue_id: '#meta' }); + expect(cmd.input.UpdateExpression).toBe('SET retry_comment_id = :cid'); + expect(cmd.input.ExpressionAttributeValues).toEqual({ ':cid': 'retry-cmt-1' }); + }); + + test('clearing REMOVEs it, so a later settle cannot re-answer an answered comment', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({}); + + await setRetryCommentId(ddb as never, TABLE, 'orch_1', undefined); + + const cmd = ddb.send.mock.calls[0][0] as { input: Record<string, unknown> }; + expect(cmd.input.UpdateExpression).toBe('REMOVE retry_comment_id'); + // No stray value binding on a REMOVE — DynamoDB rejects unused ones. + expect(cmd.input.ExpressionAttributeValues).toBeUndefined(); + }); + + test('loadOrchestration surfaces it on the meta snapshot', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ + orchestration_id: 'orch_1', + sub_issue_id: '#meta', + parent_issue_ref: 'PARENT', + credentials_ref: 'WS', + repo: 'o/r', + child_count: 1, + platform_user_id: 'u1', + retry_comment_id: 'retry-cmt-1', + }], + }); + + const snap = await loadOrchestration(ddb as never, TABLE, 'orch_1'); + expect(snap?.meta.retry_comment_id).toBe('retry-cmt-1'); + }); +}); + +describe('findOrchestrationChildByBranch — map a PR branch back to its child row', () => { + test('queries the ChildBranchIndex GSI by branch and returns the child row', async () => { + const ddb = makeDdb(); + const row = { orchestration_id: 'orch_1', sub_issue_id: 'SUB-A', child_branch_name: 'bgagent/01T/abca-1-x' }; + ddb.send.mockResolvedValueOnce({ Items: [row] }); + + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'bgagent/01T/abca-1-x', + ); + + // Marshalled, so the neutral refs are always present (empty when the row + // carried neither naming, as this minimal fixture does). + expect(result).toEqual({ ...row, parent_issue_ref: '', credentials_ref: '' }); + const cmd = ddb.send.mock.calls[0][0] as QueryCommand; + expect(cmd).toBeInstanceOf(QueryCommand); + expect(cmd.input.IndexName).toBe('ChildBranchIndex'); + expect(cmd.input.KeyConditionExpression).toBe('child_branch_name = :b'); + expect(cmd.input.ExpressionAttributeValues).toEqual({ ':b': 'bgagent/01T/abca-1-x' }); + expect(cmd.input.Limit).toBe(1); + }); + + test('marshals the row, so a pre-rename row still yields its parent + credentials refs', async () => { + // A raw cast type-checked while leaving the renamed attributes unread, which + // would hand the caller a row with empty refs rather than failing visibly. + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ + Items: [{ + orchestration_id: 'orch_1', + sub_issue_id: 'SUB-A', + child_branch_name: 'bgagent/01T/abca-1-x', + parent_linear_issue_id: 'parent-uuid-1', + linear_workspace_id: 'ws-uuid-1', + linear_identifier: 'ABCA-1', + }], + }); + + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'bgagent/01T/abca-1-x', + ); + + expect(result).toMatchObject({ + parent_issue_ref: 'parent-uuid-1', + credentials_ref: 'ws-uuid-1', + display_id: 'ABCA-1', + }); + }); + + test('returns null when no released child owns the branch (non-orchestration PR)', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Items: [] }); + const result = await findOrchestrationChildByBranch( + ddb as never, TABLE, 'ChildBranchIndex', 'feature/some-human-branch', + ); + expect(result).toBeNull(); + }); +}); + +describe('extendOrchestration — add nodes to an already-seeded epic', () => { + const PARENT = 'parent-issue-1'; + const ORCH = deriveOrchestrationId(PARENT); + + /** A loadOrchestration Query response: meta + existing child rows. */ + function existing(children: Array<{ id: string; deps?: string[]; status: string }>) { + const meta = { + orchestration_id: ORCH, + sub_issue_id: '#meta', + parent_linear_issue_id: PARENT, + linear_workspace_id: 'WS', + repo: 'o/r', + child_count: children.length, + platform_user_id: 'u1', + created_at: NOW, + updated_at: NOW, + }; + const rows = children.map((c) => ({ + orchestration_id: ORCH, + sub_issue_id: c.id, + parent_linear_issue_id: PARENT, + linear_workspace_id: 'WS', + repo: 'o/r', + depends_on: c.deps ?? [], + child_status: c.status, + created_at: NOW, + updated_at: NOW, + })); + return { Items: [meta, ...rows] }; + } + + function extendParams(graph: SubIssueNode[]) { + return { + tableName: TABLE, + parentIssueRef: PARENT, + credentialsRef: 'WS', + repo: 'o/r', + graph, + now: NOW, + }; + } + + test('adds a NEW node blocked-by a finished node → releasable immediately', async () => { + const ddb = makeDdb(); + // load (Query) → existing A succeeded; then BatchWrite (new rows) + Update (meta). + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])) + .mockResolvedValueOnce({}) // BatchWrite + .mockResolvedValueOnce({}); // Update meta + // Graph now has A (existing) + B (new, depends on the finished A). + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A'], { title: 'UI' })]), + }); + expect(result.addedSubIssueIds).toEqual(['B']); + expect(result.releasableSubIssueIds).toEqual(['B']); // A already succeeded + // The new row was written as 'ready' (deps satisfied). + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; child_status: string } } }>)[0].PutRequest.Item; + expect(written.sub_issue_id).toBe('B'); + expect(written.child_status).toBe('ready'); + }); + + test('adds a NEW node whose predecessor is NOT yet done → blocked, not releasable', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'released' }])) // A still running + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A'])]), + }); + expect(result.addedSubIssueIds).toEqual(['B']); + expect(result.releasableSubIssueIds).toEqual([]); // A not succeeded → B blocked + }); + + // A new node with NO declared dependency stacks on the epic TIP (the leaf + // frontier of the existing nodes), not on the bare default branch. + test('new UNCONSTRAINED node → implicit depends_on = epic tip (linear chain → its leaf)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, + { id: 'B', deps: ['A'], status: 'succeeded' }, // B is the leaf / tip + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + // New node C declares NO dependency. + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', [], { title: 'New step' })]), + }); + expect(result.addedSubIssueIds).toEqual(['C']); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; depends_on: string[]; child_status: string } } }>)[0].PutRequest.Item; + expect(written.sub_issue_id).toBe('C'); + // Stacked on the tip B (not []), and B succeeded so C is releasable. + expect(written.depends_on).toEqual(['B']); + expect(written.child_status).toBe('ready'); + expect(result.releasableSubIssueIds).toEqual(['C']); + }); + + test('new unconstrained node, tip NOT done → blocked on the tip (stacks, waits)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'released' }])) // tip A still running + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', [])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { depends_on: string[]; child_status: string } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['A']); // stacked on the tip + expect(written.child_status).toBe('blocked'); + expect(result.releasableSubIssueIds).toEqual([]); + }); + + test('new unconstrained node on a fan-out epic → diamond implicit deps (all leaves)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'R', status: 'succeeded' }, + { id: 'B', deps: ['R'], status: 'succeeded' }, + { id: 'C', deps: ['R'], status: 'succeeded' }, // B and C are both leaves + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('R'), child('B', ['R']), child('C', ['R']), child('D', [])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { sub_issue_id: string; depends_on: string[] } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['B', 'C']); // diamond over both leaves + expect(result.releasableSubIssueIds).toEqual(['D']); // both succeeded + }); + + test('new node WITH an explicit dependency keeps it (user intent wins over the tip)', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, + { id: 'B', deps: ['A'], status: 'succeeded' }, // tip would be B + ])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + // New node C explicitly depends on A (not the tip B). + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', ['A'])]), + }); + const bw = ddb.send.mock.calls.find((c) => c[0] instanceof BatchWriteCommand)![0]; + const written = (bw.input.RequestItems[TABLE] as Array<{ PutRequest: { Item: { depends_on: string[] } } }>)[0].PutRequest.Item; + expect(written.depends_on).toEqual(['A']); // explicit edge preserved, NOT overridden to ['B'] + expect(result.addedSubIssueIds).toEqual(['C']); + }); + + test('no new nodes (graph unchanged) → no-op, no writes', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])); + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A')]), + }); + expect(result.addedSubIssueIds).toEqual([]); + // Only the load Query ran — no BatchWrite/Update. + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof BatchWriteCommand)).toHaveLength(0); + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof UpdateCommand)).toHaveLength(0); + }); + + test('a new edge that introduces a CYCLE → rejected, nothing written', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce(existing([ + { id: 'A', status: 'succeeded' }, { id: 'B', deps: ['A'], status: 'succeeded' }, + ])); + // New node C depends on B, but the augmented graph also makes A depend on C → cycle. + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A', ['C']), child('B', ['A']), child('C', ['B'])]), + }); + expect(result.rejected?.reason).toBe('cycle'); + expect(result.addedSubIssueIds).toEqual([]); + expect(ddb.send.mock.calls.filter((c) => c[0] instanceof BatchWriteCommand)).toHaveLength(0); + }); + + test('no existing orchestration (load returns nothing) → empty result', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Items: [] }); // loadOrchestration → null + const result = await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A')]), + }); + expect(result.addedSubIssueIds).toEqual([]); + }); + + test('bumps meta child_count by the number of added nodes', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }, { id: 'B', deps: ['A'], status: 'succeeded' }])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', ['A']), child('C', ['A']), child('D', ['B'])]), + }); + const upd = ddb.send.mock.calls.find((c) => c[0] instanceof UpdateCommand)![0]; + // 2 existing + 2 new (C, D) = 4. + expect(upd.input.ExpressionAttributeValues[':n']).toBe(4); + }); + + test('clears rollup_posted_at so an epic extended after completion can roll up again', async () => { + const ddb = makeDdb(); + ddb.send + .mockResolvedValueOnce(existing([{ id: 'A', status: 'succeeded' }])) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await extendOrchestration({ + ddb: ddb as never, + ...extendParams([child('A'), child('B', [])]), + }); + const upd = ddb.send.mock.calls.find((c) => c[0] instanceof UpdateCommand)![0]; + // The meta update REMOVEs rollup_posted_at so the reconciler can re-claim + // and re-settle the parent state when the added node finishes. + expect(upd.input.UpdateExpression).toContain('REMOVE rollup_posted_at'); + }); +}); From 7acbc5d55609fcc596b69c6a2d8347e6a92877c9 Mon Sep 17 00:00:00 2001 From: Sphia Sadek <isadeks@gmail.com> Date: Mon, 27 Jul 2026 19:27:34 +0100 Subject: [PATCH 2/3] fix(carve S5): keep no-shadow enforced in tests rather than relaxing it repo-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test-scope lint relaxation switched off four rules across all 152 test files to accommodate 13 new ones. `max-len` and `no-magic-numbers` are fair there — literal fixtures and long expected strings are the point of a test. `no-shadow` is not: it is correctness-adjacent in test code, where a shadowed `row` or `mock` inside a nested describe is a common way to assert against the wrong fixture and still pass. So `no-shadow` stays on, and the two shadows it found are fixed rather than silenced. One was a redundant local `makeDdb` identical to the file-level helper (deleted). The other was a local `child` that builds a persisted ROW shadowing a file-level `child` that builds a graph node — genuinely two different shapes under one name, which is exactly the hazard. Renamed to say which it is. --- cdk/eslint.config.mjs | 10 +++++ cdk/src/handlers/shared/linear-feedback.ts | 38 +++++++++---------- .../handlers/shared/linear-subissue-fetch.ts | 2 +- .../shared/orchestration-channel-linear.ts | 4 +- .../shared/orchestration-comment-trigger.ts | 6 +-- cdk/src/handlers/shared/orchestration-dag.ts | 2 +- .../shared/orchestration-graph-source.ts | 4 +- .../handlers/shared/orchestration-store.ts | 4 +- .../handlers/shared/linear-feedback.test.ts | 16 ++++---- .../shared/orchestration-channel.test.ts | 14 +++---- .../orchestration-comment-trigger.test.ts | 2 +- .../shared/orchestration-store.test.ts | 14 +++---- 12 files changed, 62 insertions(+), 54 deletions(-) diff --git a/cdk/eslint.config.mjs b/cdk/eslint.config.mjs index fbd92691e..a7a58ffd6 100644 --- a/cdk/eslint.config.mjs +++ b/cdk/eslint.config.mjs @@ -259,7 +259,17 @@ export default [ { files: ['test/**/*.ts'], rules: { + // Literal fixtures and expected values are the point of a test; naming every + // one defeats readability. '@typescript-eslint/no-magic-numbers': 'off', + // Table-driven assertions and long expected strings read better on one line + // than wrapped. + '@stylistic/max-len': 'off', + 'max-len': 'off', + // NOTE: `no-shadow` is deliberately NOT relaxed here. It is + // correctness-adjacent in test code — a shadowed `row` or `mock` inside a + // nested describe is a common way to assert against the wrong fixture and + // still pass. Rename the inner binding instead. }, }, ]; diff --git a/cdk/src/handlers/shared/linear-feedback.ts b/cdk/src/handlers/shared/linear-feedback.ts index 0baa00e04..aa97e0aab 100644 --- a/cdk/src/handlers/shared/linear-feedback.ts +++ b/cdk/src/handlers/shared/linear-feedback.ts @@ -89,7 +89,7 @@ mutation DeleteComment($id: String!) { /** * List an issue's TOP-LEVEL comments (id + body). Used to tidy the thread once a - * proposed decomposition plan is approved or rejected: we can't + * pending proposal is settled: we can't * track every fire-and-forget note id (they're posted from ~15 sites), so we * fetch the thread once and delete the bot's own ``🗂️``/``👋`` notes by prefix, * keeping the frozen plan reference + the (differently-prefixed) live panel. @@ -442,24 +442,22 @@ export async function deleteComment( } /** - * Bot-comment prefixes that mark a TRANSIENT note from the plan-proposal phase, - * where the platform decomposes a plain issue into a sub-issue graph for a human - * to approve (the "on it" ack, revise/escalate acks, planner-error, over-cap, - * already-decomposed, nudge, wrong-handle). These are the ``🗂️``/``👋`` comments - * the plan cleanup sweeps once the plan is approved or rejected. - * Deliberately does NOT include the live epic panel - * (``🔄``/``⚠️``/``✅``) or the agent's own progress (``🤖``) — those aren't ours - * to delete here, and the panel is the thing we're KEEPING. Mirrors the - * self-trigger guard's ``BOT_COMMENT_PREFIXES`` but scoped to the decompose notes. + * Bot-comment prefixes that mark a TRANSIENT note — an interim ack, an error note, + * a nudge, a wrong-handle correction. These are the ``🗂️``/``👋`` comments a + * thread cleanup sweeps once the thing they were about is settled. + * + * Deliberately does NOT include the live epic panel (``🔄``/``⚠️``/``✅``) or the + * agent's own progress (``🤖``) — those aren't ours to delete here, and the panel + * is the thing we're KEEPING. Mirrors the self-trigger guard's + * ``BOT_COMMENT_PREFIXES`` but scoped to transient notes. */ -const DECOMPOSE_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; +const TRANSIENT_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; /** - * Sweep the bot's transient decomposition notes off an - * issue once the plan is approved/rejected, leaving just the frozen plan - * reference + the live epic panel. Fetches the thread once, then deletes every - * top-level comment that (a) starts with a decompose-note prefix and (b) is NOT - * ``keepCommentId`` (the frozen plan reference we just wrote). Best-effort and + * Sweep the bot's transient notes off an issue once the thing they were about is + * settled, leaving any frozen reference + the live epic panel. Fetches the thread + * once, then deletes every top-level comment that (a) starts with a transient-note + * prefix and (b) is NOT ``keepCommentId`` (a reference to keep). Best-effort and * total: a failed list returns 0 (nothing swept — a lingering note is a * cosmetic nit, never a breakage), and each delete is independent so one * failure doesn't abort the rest. Returns the count deleted (for logging/tests). @@ -469,7 +467,7 @@ const DECOMPOSE_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; * covered automatically — while the panel (different prefix) and human comments * (no bot prefix) are provably untouched. */ -export async function sweepDecompositionNotes( +export async function sweepTransientNotes( ctx: LinearFeedbackContext, issueId: string, keepCommentId?: string, @@ -484,12 +482,12 @@ export async function sweepDecompositionNotes( const id = node?.id; const body = (node?.body ?? '').trimStart(); if (!id || id === keepCommentId) continue; - if (!DECOMPOSE_NOTE_PREFIXES.some((p) => body.startsWith(p))) continue; + if (!TRANSIENT_NOTE_PREFIXES.some((p) => body.startsWith(p))) continue; const ok = (await graphqlRequest(token, COMMENT_DELETE_MUTATION, { id })).ok; if (ok) deleted += 1; } if (deleted > 0) { - logger.info('Swept transient decomposition notes at plan settle', { + logger.info('Swept transient notes', { issue_id: issueId, deleted, kept_reference: keepCommentId ?? null, }); } @@ -1034,7 +1032,7 @@ export async function transitionIssueState( /** * Move an issue BACKWARD to a not-started state - * (``unstarted`` "Todo", else ``backlog``), used when a ``:decompose`` planning + * (``unstarted`` "Todo", else ``backlog``), used when a planning * run finishes and the issue is now awaiting the reviewer's approve. The webhook * moved it to In Progress at dispatch (so the board showed the ~1-2 min planning * WAS happening — {@link transitionIssueState}); once the plan is posted and diff --git a/cdk/src/handlers/shared/linear-subissue-fetch.ts b/cdk/src/handlers/shared/linear-subissue-fetch.ts index 24892d427..62ec27c01 100644 --- a/cdk/src/handlers/shared/linear-subissue-fetch.ts +++ b/cdk/src/handlers/shared/linear-subissue-fetch.ts @@ -101,7 +101,7 @@ export interface SubIssueNode extends DagNode { /** Sub-issue title for the task description. */ readonly title?: string; /** - * Sub-issue scope/description. The decompose planner writes a rich + * Sub-issue scope/description. A planner writes a rich * per-piece scope — often naming a concrete deliverable (a file, a route) — * and that scope is what the reviewer approves. It must reach the coding * agent so it builds what the plan promised (e.g. the exact filename), not a diff --git a/cdk/src/handlers/shared/orchestration-channel-linear.ts b/cdk/src/handlers/shared/orchestration-channel-linear.ts index 3d9d6e491..693caee92 100644 --- a/cdk/src/handlers/shared/orchestration-channel-linear.ts +++ b/cdk/src/handlers/shared/orchestration-channel-linear.ts @@ -41,7 +41,7 @@ import { revertIssueToNotStarted, swapCommentReaction, swapIssueReaction, - sweepDecompositionNotes, + sweepTransientNotes, transitionIssueState, upsertStatusComment, upsertThreadedReply as upsertLinearThreadedReply, @@ -165,7 +165,7 @@ export function makeLinearChannel(registryTableName: string): Channel { }, async sweepNotes(issue, keep) { - return sweepDecompositionNotes(ctxFor(issue), issue.issueId, keep?.commentId); + return sweepTransientNotes(ctxFor(issue), issue.issueId, keep?.commentId); }, async fetchChildGraph(parent): Promise<readonly ChannelSubIssueNode[]> { diff --git a/cdk/src/handlers/shared/orchestration-comment-trigger.ts b/cdk/src/handlers/shared/orchestration-comment-trigger.ts index 368fd438d..8b2484f94 100644 --- a/cdk/src/handlers/shared/orchestration-comment-trigger.ts +++ b/cdk/src/handlers/shared/orchestration-comment-trigger.ts @@ -93,7 +93,7 @@ const BOT_COMMENT_PREFIXES = [ '🤖', // agent progress ("🤖 Starting…") '🖼️', // preview screenshot comment '🔗', // "PR opened" / combined-PR - '🗂️', // plan proposal / decomposition notes (embed literal "@bgagent approve") + '🗂️', // transient platform notes (may embed a literal "@bgagent …" instruction) '💬', // maturing-reply "answered" state (a no-change/question iteration) '👀', // instant "on it" ack reply (posted at trigger time) ] as const; @@ -119,7 +119,7 @@ export function isBotAuthoredComment(body: string): boolean { * near-misses qualify. Matching is done by {@link detectNearMissMention}. */ const NEAR_MISS_MENTION_PATTERNS: readonly RegExp[] = [ - // @abca (+ optional :suffix like @abca:decompose) — the label-name confusion. + // @abca (+ optional :suffix) — the label-name confusion. /@abca\b/i, // @bgagent immediately followed by a word char — a boundary-miss that // parseCommentTrigger's `@bgagent(?![\w.])` deliberately does NOT trigger @@ -201,7 +201,7 @@ export function parseRetryIntent(instruction: string): boolean { export const KNOWN_EPIC_COMMANDS = ['retry'] as const; /** - * The verdict of an ``@bgagent`` comment on a pending decomposition + * The verdict of an ``@bgagent`` comment on a pending * plan — the proposed sub-issue breakdown of a plain issue, awaiting a * reviewer's go-ahead before any work starts. * ``none`` means the comment is an ordinary change instruction (routes to diff --git a/cdk/src/handlers/shared/orchestration-dag.ts b/cdk/src/handlers/shared/orchestration-dag.ts index 7de38e202..8cbaece69 100644 --- a/cdk/src/handlers/shared/orchestration-dag.ts +++ b/cdk/src/handlers/shared/orchestration-dag.ts @@ -24,7 +24,7 @@ * layering the reconciler uses to release children in dependency order. * * Kept deliberately free of Linear/AWS types so it is trivially unit- - * testable and reusable by the auto-decomposition planner, which + * testable and reusable by a planner, which * validates its own generated graph with the same cycle check before * writing sub-issues back to the tracker. */ diff --git a/cdk/src/handlers/shared/orchestration-graph-source.ts b/cdk/src/handlers/shared/orchestration-graph-source.ts index 117ea9e8f..1a7aad51d 100644 --- a/cdk/src/handlers/shared/orchestration-graph-source.ts +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -37,7 +37,7 @@ * caller SUPPLIES the DAG. {@link declarativeGraphSource} takes a * ready-made node list. This is the slot for: * - CLI / API: a request body carrying tasks + ``depends_on`` edges. - * - Auto-decomposition: a planner agent turns ONE plain request into + * - A planner agent turning ONE plain request into * a phased DAG and hands the nodes here — reusing the ENTIRE * executor instead of reimplementing gating/stacking/rollup. * @@ -89,7 +89,7 @@ export function linearGraphSource( /** * Tier 2 — declarative graph. The caller already has the node list (a - * CLI/API request, or a planner's decomposition output). An empty + * CLI/API request, or a planner's output). An empty * list means "no graph" → single task. Never errors (the nodes are * in-memory); DAG validity (cycles/dangling/dupes) is still enforced * downstream by ``validateDag`` in the discovery composer. diff --git a/cdk/src/handlers/shared/orchestration-store.ts b/cdk/src/handlers/shared/orchestration-store.ts index fcae203a0..fd53fb59e 100644 --- a/cdk/src/handlers/shared/orchestration-store.ts +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -92,7 +92,7 @@ export interface OrchestrationChildRow { readonly title?: string; /** * Sub-issue scope/description. When the graph came from the planner - * decomposing a plain issue, this is its rich per-piece scope — persisted at + * a planner produced the piece, this is its rich per-piece scope — persisted at * seed so the coding agent's task_description carries what the reviewer * approved (e.g. a promised filename), not the title alone. Absent when the * graph was read from sub-issues a human already wrote, since that path @@ -859,7 +859,7 @@ export async function loadOrchestration( // A non-epic issue accumulates dedup MARKER rows (``ack#…``) under the same // derived id without ever being seeded, so "rows but no meta" is the normal // shape there, not a broken orchestration — a plain `@bgagent` on any - // never-decomposed issue reaches this. Only warn when a REAL child row is + // plain issue reaches this. Only warn when a REAL child row is // present, which is the genuinely inconsistent case; otherwise this cried wolf // on a healthy path and taught readers to ignore the log. const hasRealChild = items.some((i) => !String(i.sub_issue_id).includes('#')); diff --git a/cdk/test/handlers/shared/linear-feedback.test.ts b/cdk/test/handlers/shared/linear-feedback.test.ts index c5547b262..68f124349 100644 --- a/cdk/test/handlers/shared/linear-feedback.test.ts +++ b/cdk/test/handlers/shared/linear-feedback.test.ts @@ -37,7 +37,7 @@ import { replyToComment, reportIssueFailure, revertIssueToNotStarted, - sweepDecompositionNotes, + sweepTransientNotes, swapCommentReaction, swapIssueReaction, transitionIssueState, @@ -907,9 +907,9 @@ describe('linear-feedback', () => { }); }); - describe('sweepDecompositionNotes — tidy the planning chatter once a plan is approved', () => { + describe('sweepTransientNotes — tidy the planning chatter once a plan is approved', () => { // A representative plan-phase thread: the frozen plan reference (KEEP), the - // transient decompose notes (🗂️/👋 → DELETE), the live epic panel (🔄 → a + // transient notes (🗂️/👋 → DELETE), the live epic panel (🔄 → a // different prefix, KEEP), and a human comment (no bot prefix, KEEP). const THREAD = { data: { @@ -931,7 +931,7 @@ describe('linear-feedback', () => { fetchMock .mockResolvedValueOnce(jsonResponse(THREAD)) // the comments list .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); - const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); // started-ack + nudge deleted; plan-ref (kept), panel (🔄), human (no prefix) survive. expect(deleted).toBe(2); const deletedIds = fetchMock.mock.calls @@ -947,21 +947,21 @@ describe('linear-feedback', () => { fetchMock .mockResolvedValueOnce(jsonResponse(THREAD)) .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); - const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID); // plan-ref + started-ack + nudge (all 🗂️/👋); panel + human still spared. expect(deleted).toBe(3); }); test('no token → no fetch, sweeps nothing', async () => { resolveLinearOauthTokenMock.mockResolvedValueOnce(null); - const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); expect(deleted).toBe(0); expect(fetchMock).not.toHaveBeenCalled(); }); test('a failed comments-list is a clean no-op (best-effort, never throws)', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'boom' }] }, 200)); - const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); expect(deleted).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); // only the (failed) list; no deletes }); @@ -972,7 +972,7 @@ describe('linear-feedback', () => { data: { issue: { comments: { nodes: [{ id: 'ws', body: '\n 🗂️ over-cap note' }] } } }, })) .mockResolvedValue(jsonResponse({ data: { commentDelete: { success: true } } })); - const deleted = await sweepDecompositionNotes(CTX, ISSUE_ID, 'plan-ref'); + const deleted = await sweepTransientNotes(CTX, ISSUE_ID, 'plan-ref'); expect(deleted).toBe(1); }); }); diff --git a/cdk/test/handlers/shared/orchestration-channel.test.ts b/cdk/test/handlers/shared/orchestration-channel.test.ts index 71c023b98..36942f94a 100644 --- a/cdk/test/handlers/shared/orchestration-channel.test.ts +++ b/cdk/test/handlers/shared/orchestration-channel.test.ts @@ -29,7 +29,7 @@ const linearTransitionIssueState = jest.fn(); const linearRevertIssueToNotStarted = jest.fn(); const linearReplyToComment = jest.fn(); const linearUpsertThreadedReply = jest.fn(); -const linearSweepDecompositionNotes = jest.fn(); +const linearSweepTransientNotes = jest.fn(); jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ EMOJI_STARTED: 'eyes', EMOJI_SUCCESS: 'white_check_mark', @@ -45,7 +45,7 @@ jest.mock('../../../src/handlers/shared/linear-feedback', () => ({ revertIssueToNotStarted: (...a: unknown[]) => linearRevertIssueToNotStarted(...a), replyToComment: (...a: unknown[]) => linearReplyToComment(...a), upsertThreadedReply: (...a: unknown[]) => linearUpsertThreadedReply(...a), - sweepDecompositionNotes: (...a: unknown[]) => linearSweepDecompositionNotes(...a), + sweepTransientNotes: (...a: unknown[]) => linearSweepTransientNotes(...a), })); const resolveLinearOauthToken = jest.fn(); @@ -189,16 +189,16 @@ describe('Linear channel adapter', () => { }); test('sweepNotes passes the comment to keep through and returns the deleted count', async () => { - linearSweepDecompositionNotes.mockResolvedValue(3); + linearSweepTransientNotes.mockResolvedValue(3); expect(await ch.sweepNotes!(linearIssue, { commentId: 'keep-1' })).toBe(3); - expect(linearSweepDecompositionNotes.mock.calls[0][1]).toBe('lin-issue-1'); - expect(linearSweepDecompositionNotes.mock.calls[0][2]).toBe('keep-1'); + expect(linearSweepTransientNotes.mock.calls[0][1]).toBe('lin-issue-1'); + expect(linearSweepTransientNotes.mock.calls[0][2]).toBe('keep-1'); }); test('sweepNotes with nothing to keep passes no keep id', async () => { - linearSweepDecompositionNotes.mockResolvedValue(0); + linearSweepTransientNotes.mockResolvedValue(0); expect(await ch.sweepNotes!(linearIssue)).toBe(0); - expect(linearSweepDecompositionNotes.mock.calls[0][2]).toBeUndefined(); + expect(linearSweepTransientNotes.mock.calls[0][2]).toBeUndefined(); }); test('fetchChildGraph maps blocks-derived depends_on to the neutral node shape', async () => { diff --git a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts index a0196a409..f48e64894 100644 --- a/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts +++ b/cdk/test/handlers/shared/orchestration-comment-trigger.test.ts @@ -133,7 +133,7 @@ describe('detectNearMissMention — a wrong-handle mention gets a nudge, not sil expect(detectNearMissMention('@abca approve')).toBe(true); expect(detectNearMissMention('hey @abca can you make it 2 tasks')).toBe(true); // …even with a :suffix the reviewer copied from the label. - expect(detectNearMissMention('@abca:decompose please')).toBe(true); + expect(detectNearMissMention('@abca:auto please')).toBe(true); }); test('a boundary-miss @bgagent handle is a near-miss (parseCommentTrigger deliberately skips it)', () => { diff --git a/cdk/test/handlers/shared/orchestration-store.test.ts b/cdk/test/handlers/shared/orchestration-store.test.ts index eb5b02158..da2750c80 100644 --- a/cdk/test/handlers/shared/orchestration-store.test.ts +++ b/cdk/test/handlers/shared/orchestration-store.test.ts @@ -408,8 +408,6 @@ describe('seedOrchestration — idempotent replay', () => { }); describe('claimRollup — exactly-once parent rollup', () => { - function makeDdb(): MockDdb { return { send: jest.fn() }; } - test('first claim wins (conditional write succeeds) → true', async () => { const ddb = makeDdb(); ddb.send.mockResolvedValueOnce({}); @@ -482,7 +480,9 @@ describe('renamed row attributes stay readable across the rename', () => { // epic mid-flight when the rename ships must still load and settle. These pin // BOTH spellings, and the fact that a row carrying only the legacy names loads // is the actual back-compat guarantee. - const child = (extra: Record<string, unknown>) => ({ + // Named for what it builds — a persisted ROW — to keep it distinct from the + // file-level `child`, which builds a SubIssueNode (an input, not a row). + const childRow = (extra: Record<string, unknown>) => ({ orchestration_id: 'orch_1', sub_issue_id: 'uuid-A', depends_on: [], child_status: 'succeeded', ...extra, }); @@ -499,7 +499,7 @@ describe('renamed row attributes stay readable across the rename', () => { parent_linear_issue_id: 'P-old', linear_workspace_id: 'WS-old', }, - child({ parent_linear_issue_id: 'P-old', linear_workspace_id: 'WS-old', linear_identifier: 'ENG-1' }), + childRow({ parent_linear_issue_id: 'P-old', linear_workspace_id: 'WS-old', linear_identifier: 'ENG-1' }), ], }), }; @@ -524,7 +524,7 @@ describe('renamed row attributes stay readable across the rename', () => { parent_issue_ref: 'P-new', credentials_ref: 'WS-new', }, - child({ parent_issue_ref: 'P-new', credentials_ref: 'WS-new', display_id: 'ENG-2' }), + childRow({ parent_issue_ref: 'P-new', credentials_ref: 'WS-new', display_id: 'ENG-2' }), ], }), }; @@ -551,7 +551,7 @@ describe('renamed row attributes stay readable across the rename', () => { credentials_ref: 'WS-new', linear_workspace_id: 'WS-old', }, - child({ parent_issue_ref: 'P-new', parent_linear_issue_id: 'P-old' }), + childRow({ parent_issue_ref: 'P-new', parent_linear_issue_id: 'P-old' }), ], }), }; @@ -676,7 +676,7 @@ describe('loadOrchestration — "rows but no meta" is only alarming with a real }; beforeEach(() => { loggerMock.info.mockClear(); loggerMock.warn.mockClear(); }); - // A never-decomposed issue still accumulates dedup MARKER rows (`ack#…`) under the + // A plain issue still accumulates dedup MARKER rows (`ack#…`) under the // same derived id, so this state is NORMAL for it — a plain `@bgagent` on any such // issue reaches it. Warning there cried wolf on a healthy path. test('marker rows only → info, not warn', async () => { From 2fd66713fe9690c590fdce8465849fb77828a604 Mon Sep 17 00:00:00 2001 From: Sphia Sadek <isadeks@gmail.com> Date: Tue, 28 Jul 2026 21:14:36 +0100 Subject: [PATCH 3/3] docs(carve S5): fix comments that describe code this slice does not contain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught two inaccurate comments. Both were worth fixing, and looking for more of the same class turned up two others. The integration-node comment gave the synthetic id as `<orchestrationId>#integration` while the code builds `__integration`. Not a harmless typo: the `#` form is exactly what the comment thirty lines above warns will 400 the child, because the id flows into an idempotency key validated against /^[a-zA-Z0-9_-]{1,128}$/. A reader trusting the example would reach for the one separator that breaks it. Now points at the suffix constant so the two cannot drift again. Three comments referenced symbols that are not in this slice: - `findOrchestrationIds` — real, but in the stranded-orchestration reconciler, which lands later. Replaced the cross-reference with the property it was there to convey: every paginated read of this table must follow LastEvaluatedKey, because a single page is a silent partial answer rather than an error. - `discoverOrchestration` — the consumer lands with the compute plane; now described by role instead of by name. - `renderFailureReply` — called "the existing" renderer when nothing of that name exists here yet, which is the most misleading form: it reads as a claim about the current tree. Now describes it as arriving with the activation slice. A comment naming a symbol a reader cannot grep is a dead end, and in a stacked series it is an easy defect to introduce, since the symbol does exist on the branch the slice was carved from. --- cdk/src/handlers/shared/iteration-reply.ts | 5 +++-- cdk/src/handlers/shared/orchestration-graph-source.ts | 5 +++-- cdk/src/handlers/shared/orchestration-integration-node.ts | 4 +++- cdk/src/handlers/shared/orchestration-store.ts | 6 ++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cdk/src/handlers/shared/iteration-reply.ts b/cdk/src/handlers/shared/iteration-reply.ts index 96870dffb..96c9bebcc 100644 --- a/cdk/src/handlers/shared/iteration-reply.ts +++ b/cdk/src/handlers/shared/iteration-reply.ts @@ -252,8 +252,9 @@ export interface IterationReplyInput { /** * Render the reply for a SUCCESSFUL (completed + build-passing) iteration. - * (Failures are rendered by the existing ``renderFailureReply`` — this is only - * the success branch, which is where the false-"✅ Updated" lived.) + * (Failures are rendered by a separate failure-reply renderer that arrives with + * the activation slice — this is only the success branch, which is where the + * false-"✅ Updated" lived.) */ export function renderIterationSuccessReply(input: IterationReplyInput): string { const noChange = input.codeChanged === false; diff --git a/cdk/src/handlers/shared/orchestration-graph-source.ts b/cdk/src/handlers/shared/orchestration-graph-source.ts index 1a7aad51d..f34d9b4b8 100644 --- a/cdk/src/handlers/shared/orchestration-graph-source.ts +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -46,8 +46,9 @@ * (tier 1). No adapter needed here. * * A source is a zero-arg async thunk so the caller binds whatever inputs - * it needs (token + issue id for Linear; a node list for declarative) - * before handing ``discoverOrchestration`` a uniform interface. + * it needs (token + issue id for Linear; a node list for declarative) before + * handing the discovery step a uniform interface — the consumer that resolves a + * graph from one of these sources lands with the orchestration compute plane. */ import { fetchSubIssueGraph, type FetchSubIssueGraphOptions, type SubIssueNode } from './linear-subissue-fetch'; diff --git a/cdk/src/handlers/shared/orchestration-integration-node.ts b/cdk/src/handlers/shared/orchestration-integration-node.ts index c7c90e1d4..4aa56959d 100644 --- a/cdk/src/handlers/shared/orchestration-integration-node.ts +++ b/cdk/src/handlers/shared/orchestration-integration-node.ts @@ -76,7 +76,9 @@ export function computeLeaves(nodes: readonly SubIssueNode[]): readonly string[] * all leaves) when there is more than one leaf. * * ``orchestrationId`` namespaces the synthetic node's id so it is unique + - * recognizable (``<orchestrationId>#integration``). The node carries no + * recognizable (``<orchestrationId>__integration`` — see + * {@link INTEGRATION_NODE_SUFFIX} for why the separator is ``_`` and not ``#``). + * The node carries no * ``identifier`` (there is no Linear issue) and a fixed ``title`` so the * status block / rollup render "Integration …" gracefully. */ diff --git a/cdk/src/handlers/shared/orchestration-store.ts b/cdk/src/handlers/shared/orchestration-store.ts index fd53fb59e..5e671191d 100644 --- a/cdk/src/handlers/shared/orchestration-store.ts +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -832,8 +832,10 @@ export interface OrchestrationSnapshot { * Paginates: a DynamoDB Query returns at most one 1MB page, so a large epic * (many children + accumulated ``ack#`` marker rows) would otherwise silently * truncate — dropping children from the completion check / panel and stranding - * the epic. Follows ``LastEvaluatedKey`` to read all rows (mirrors - * ``findOrchestrationIds``'s Scan pagination). + * the epic. Follows ``LastEvaluatedKey`` to read all rows. Every paginated read + * of this table must do the same: a single page is a silent partial answer here, + * not an error, so the truncation shows up as a wrong decision rather than a + * failure. */ export async function loadOrchestration( ddb: DynamoDBDocumentClient,