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/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..96c9bebcc --- /dev/null +++ b/cdk/src/handlers/shared/iteration-reply.ts @@ -0,0 +1,343 @@ +/** + * 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 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; + + 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..aa97e0aab 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 + * 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. + * ``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,181 @@ 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 — 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 TRANSIENT_NOTE_PREFIXES: readonly string[] = ['🗂️', '👋']; + +/** + * 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). + * + * 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 sweepTransientNotes( + 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 (!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 notes', { + 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 +585,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 +912,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 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..62ec27c01 --- /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. 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 + * 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..693caee92 --- /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, + sweepTransientNotes, + 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 sweepTransientNotes(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..8b2484f94 --- /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 + '🗂️', // 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; + +/** 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) — 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 + * 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..8cbaece69 --- /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 a 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..f34d9b4b8 --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -0,0 +1,103 @@ +/** + * 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. + * - 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. + * + * 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 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'; + +/** + * 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 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..4aa56959d --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-integration-node.ts @@ -0,0 +1,99 @@ +/** + * 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`` — 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. + */ +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..5e671191d --- /dev/null +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -0,0 +1,956 @@ +/** + * 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 + * 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 + * 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. 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, + 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 + // 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('#')); + 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..68f124349 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, + sweepTransientNotes, + 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('sweepTransientNotes — tidy the planning chatter once a plan is approved', () => { + // A representative plan-phase thread: the frozen plan reference (KEEP), the + // transient 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 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 + .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 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 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 sweepTransientNotes(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 sweepTransientNotes(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..36942f94a --- /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 linearSweepTransientNotes = 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), + sweepTransientNotes: (...a: unknown[]) => linearSweepTransientNotes(...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 () => { + linearSweepTransientNotes.mockResolvedValue(3); + expect(await ch.sweepNotes!(linearIssue, { commentId: 'keep-1' })).toBe(3); + 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 () => { + linearSweepTransientNotes.mockResolvedValue(0); + expect(await ch.sweepNotes!(linearIssue)).toBe(0); + expect(linearSweepTransientNotes.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..f48e64894 --- /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:auto 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..da2750c80 --- /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', () => { + 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. + // 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, + }); + + 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', + }, + childRow({ 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', + }, + childRow({ 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', + }, + childRow({ 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 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 () => { + 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'); + }); +});