From 87bf297931d2f537addd74288d4b187831a1b68c Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 1 Jun 2026 14:35:48 +0200 Subject: [PATCH 01/25] feat(pm,github): mutation result contracts and PM timestamp plumbing (MNG-1422) (#1386) * feat(pm,github): mutation result contracts and PM timestamp plumbing (MNG-1422) * fix: address feedback --------- Co-authored-by: Cascade Bot --- src/gadgets/github/core/mutationResults.ts | 150 +++++++++++++++++ src/gadgets/pm/core/mutationResults.ts | 153 ++++++++++++++++++ src/jira/client.ts | 10 +- src/pm/jira/adapter.ts | 22 +++ src/pm/linear/adapter.ts | 12 ++ src/pm/trello/adapter.ts | 17 ++ src/pm/types.ts | 28 ++++ src/trello/client.ts | 9 ++ tests/helpers/fakePMProvider.ts | 49 +++++- tests/helpers/mockPMProvider.ts | 75 +++++++++ .../github/core/mutationResults.test.ts | 135 ++++++++++++++++ .../gadgets/pm/core/mutationResults.test.ts | 150 +++++++++++++++++ .../integrations/pm-fake-lifecycle.test.ts | 91 ++++++++++- .../pm-mock-provider-timestamps.test.ts | 68 ++++++++ tests/unit/pm/jira/adapter.test.ts | 82 ++++++++++ tests/unit/pm/linear/adapter.test.ts | 47 ++++++ tests/unit/pm/trello/adapter.test.ts | 75 +++++++++ 17 files changed, 1170 insertions(+), 3 deletions(-) create mode 100644 src/gadgets/github/core/mutationResults.ts create mode 100644 src/gadgets/pm/core/mutationResults.ts create mode 100644 tests/unit/gadgets/github/core/mutationResults.test.ts create mode 100644 tests/unit/gadgets/pm/core/mutationResults.test.ts create mode 100644 tests/unit/integrations/pm-mock-provider-timestamps.test.ts diff --git a/src/gadgets/github/core/mutationResults.ts b/src/gadgets/github/core/mutationResults.ts new file mode 100644 index 000000000..262845ed5 --- /dev/null +++ b/src/gadgets/github/core/mutationResults.ts @@ -0,0 +1,150 @@ +/** + * GitHub mutation result contract — typed outcome/status unions and helpers + * for normalizing GitHub mutation results into a stable shape with `id`, + * `url`, `status`, and `updatedAt`. + * + * Spec MNG-1422: introduces reusable GitHub mutation result types so later + * mutation cores (createPR, postPRComment, updatePRComment, replyToReviewComment, + * createPRReview, etc.) can return predictable objects instead of free-form + * prose. + * + * The GitHub helper intentionally mirrors the PM helper at + * `src/gadgets/pm/core/mutationResults.ts` so consumers can implement a single + * branching strategy on `status` regardless of the integration. The two + * modules stay siblings (not a shared utility) because the integration + * categories themselves are independent — converging the helpers would + * couple PM and SCM evolution where today they evolve on independent specs. + * + * Status semantics: + * - `'ok'` — the mutation succeeded against GitHub. The caller passes + * the provider-reported timestamp (`updated_at` from the + * GitHub REST API response). + * - `'no-op'` — the mutation gadget detected nothing to do (e.g. a PR + * create returning "already exists"). Timestamp is + * synthesised. + * - `'aborted'` — the mutation was deliberately not attempted. Same + * timestamp semantics as no-op. + * + * Timestamp policy: identical to the PM helper — provider timestamps win; + * synthetic timestamps are reserved for synthetic outcomes. + */ + +/** + * Status union for GitHub mutation outcomes. Stable across all GitHub + * mutation gadgets. + */ +export type GitHubMutationStatus = 'ok' | 'no-op' | 'aborted'; + +/** + * Normalized result shape for any GitHub mutation. GitHub resources are + * universally identified by a numeric ID at the API level; we surface it as + * a string here to stay consistent with the PM contract and to keep the + * downstream tool-result schema homogeneous. + */ +export interface GitHubMutationResult { + /** Stable identifier of the affected resource (PR number, comment ID, etc.) as a string. */ + id: string; + /** Outcome status — `'ok'` means GitHub accepted the mutation. */ + status: GitHubMutationStatus; + /** + * ISO 8601 timestamp reflecting when the resource was last updated. + * GitHub-supplied for `'ok'` outcomes (from the response's `updated_at`); + * synthesised via `currentTimestamp()` for `'no-op'` / `'aborted'` + * outcomes. + */ + updatedAt: string; + /** Optional URL of the affected resource (PR URL, comment URL). */ + url?: string; + /** Optional human-readable note explaining the outcome. */ + message?: string; +} + +/** + * Returns the current ISO 8601 timestamp. Used as the fallback for synthetic + * no-op / aborted outcomes where no GitHub write happened. + */ +export function currentTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Prefer a provider-supplied timestamp, falling back to the current ISO + * timestamp only when none is available. + * + * IMPORTANT: this fallback is intended for synthetic outcomes (no-op, + * aborted). GitHub's REST API returns `updated_at` on mutation responses, so + * `'ok'` callers must pass that real provider value to `okResult`. + */ +export function pickTimestamp(providerTimestamp: string | undefined | null): string { + if (providerTimestamp && providerTimestamp.length > 0) { + return providerTimestamp; + } + return currentTimestamp(); +} + +function requireProviderTimestamp(updatedAt: string): string { + if (typeof updatedAt !== 'string' || updatedAt.length === 0) { + throw new TypeError('okResult requires a GitHub-supplied updatedAt timestamp'); + } + return updatedAt; +} + +/** + * Build an `'ok'` mutation result. The caller must pass the GitHub response's + * `updated_at` (or equivalent) so successful results never fabricate resource + * timestamps. + */ +export function okResult(args: { + id: string | number; + updatedAt: string; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'ok', + updatedAt: requireProviderTimestamp(args.updatedAt), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build a `'no-op'` mutation result. Used when the gadget detects the + * desired state already holds (e.g. createPR finds an existing PR for the + * branch). The timestamp is synthesised because no GitHub write occurred. + */ +export function noOpResult(args: { + id: string | number; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'no-op', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build an `'aborted'` mutation result. Used when a guard refused to attempt + * the mutation. Timestamp semantics identical to no-op. + */ +export function abortedResult(args: { + id: string | number; + url?: string; + message?: string; +}): GitHubMutationResult { + const result: GitHubMutationResult = { + id: String(args.id), + status: 'aborted', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} diff --git a/src/gadgets/pm/core/mutationResults.ts b/src/gadgets/pm/core/mutationResults.ts new file mode 100644 index 000000000..5ed907b49 --- /dev/null +++ b/src/gadgets/pm/core/mutationResults.ts @@ -0,0 +1,153 @@ +/** + * PM mutation result contract — typed outcome/status unions and helpers for + * normalizing mutation results into a stable shape with `id`, `url`, `status`, + * and `updatedAt`. + * + * Spec MNG-1422: introduces reusable PM mutation result types so later + * mutation cores (createWorkItem, updateWorkItem, postComment, moveWorkItem, + * etc.) can return predictable objects instead of free-form prose. + * + * Status semantics: + * - `'ok'` — the provider accepted the mutation and we surface its + * fresh `updatedAt`. + * - `'no-op'` — there was nothing to do (e.g. the work item was already + * in the destination state). We synthesize `updatedAt` + * because no provider write happened. + * - `'aborted'` — the mutation was deliberately not attempted (e.g. + * pre-move guard mismatch). Same fallback semantics as + * no-op. + * + * Timestamp policy: + * - We always PREFER a provider-supplied timestamp when present. + * - We ONLY fall back to the current ISO timestamp for synthetic outcomes + * (`no-op` / `aborted`). For `'ok'` outcomes the caller MUST pass the + * provider timestamp. Missing provider timestamps are rejected rather than + * silently pretending the provider wrote data at "now". + */ + +/** + * Status union for PM mutation outcomes. Stable across all PM mutation + * gadgets so consumers can branch on shape, not on prose. + */ +export type PMMutationStatus = 'ok' | 'no-op' | 'aborted'; + +/** + * Normalized result shape for any PM mutation. Optional fields stay optional + * — mutations that don't touch a URL or status (e.g. a comment update) just + * omit those fields rather than carrying empty strings. + */ +export interface PMMutationResult { + /** Stable identifier of the affected resource (work item ID, comment ID, etc.). */ + id: string; + /** Outcome status — `'ok'` means the provider wrote data. */ + status: PMMutationStatus; + /** + * ISO 8601 timestamp reflecting when the resource was last updated. + * Provider-supplied for `'ok'` outcomes; synthesised via `currentTimestamp()` + * for `'no-op'` / `'aborted'` outcomes. + */ + updatedAt: string; + /** Optional URL of the affected resource (work item URL, comment URL, etc.). */ + url?: string; + /** + * Optional human-readable note explaining the outcome (e.g. "already in + * destination state"). Consumers can surface this; it's not load-bearing. + */ + message?: string; +} + +/** + * Returns the current ISO 8601 timestamp. Used as the fallback for synthetic + * no-op / aborted outcomes where no provider write happened. + * + * Centralized here so tests can spy on it without per-call-site `vi.spyOn`. + */ +export function currentTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Prefer a provider-supplied timestamp, falling back to the current ISO + * timestamp only when none is available. + * + * IMPORTANT: this fallback is intended for synthetic outcomes (no-op, + * aborted). For `'ok'` outcomes the caller must pass a provider timestamp to + * `okResult`; missing successful-resource timestamps are rejected. + * + * The helper exists to avoid littering call sites with the same `?? new + * Date().toISOString()` expression. + */ +export function pickTimestamp(providerTimestamp: string | undefined | null): string { + if (providerTimestamp && providerTimestamp.length > 0) { + return providerTimestamp; + } + return currentTimestamp(); +} + +function requireProviderTimestamp(updatedAt: string): string { + if (typeof updatedAt !== 'string' || updatedAt.length === 0) { + throw new TypeError('okResult requires a provider-supplied updatedAt timestamp'); + } + return updatedAt; +} + +/** + * Build an `'ok'` mutation result. Used by mutation cores that successfully + * wrote data through the provider. + * + * The provider timestamp is required so consumers can treat `updatedAt` on a + * successful result as provider-supplied. Synthetic timestamps are reserved + * for `no-op` and `aborted` results. + */ +export function okResult(args: { + id: string; + updatedAt: string; + url?: string; + message?: string; +}): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'ok', + updatedAt: requireProviderTimestamp(args.updatedAt), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build a `'no-op'` mutation result. Used when the mutation gadget detected + * that the desired state already holds (e.g. moveWorkItem found the item + * already in the destination state). The timestamp is the current ISO — no + * provider write happened, so we never claim a fresh provider timestamp here. + */ +export function noOpResult(args: { id: string; url?: string; message?: string }): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'no-op', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} + +/** + * Build an `'aborted'` mutation result. Used when a guard refused to attempt + * the mutation (e.g. expectedSourceState mismatch in moveWorkItem). Same + * timestamp semantics as no-op — synthesised because no write happened. + */ +export function abortedResult(args: { + id: string; + url?: string; + message?: string; +}): PMMutationResult { + const result: PMMutationResult = { + id: args.id, + status: 'aborted', + updatedAt: currentTimestamp(), + }; + if (args.url) result.url = args.url; + if (args.message) result.message = args.message; + return result; +} diff --git a/src/jira/client.ts b/src/jira/client.ts index a2a24629b..c00f6799e 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -60,6 +60,11 @@ export const jiraClient = { 'subtasks', 'attachment', 'comment', + // MNG-1422: surface provider timestamps so the PM adapter can + // hydrate `WorkItem.createdAt` / `updatedAt`. JIRA exposes + // `created` / `updated` directly on the issue fields object. + 'created', + 'updated', ], }); }, @@ -212,7 +217,10 @@ export const jiraClient = { return (issue.fields?.labels as string[]) ?? []; }, - async searchIssues(jql: string, fields: string[] = ['summary', 'status', 'labels']) { + async searchIssues( + jql: string, + fields: string[] = ['summary', 'status', 'labels', 'created', 'updated'], + ) { logger.debug('Searching JIRA issues', { jql }); const result = await getClient().issueSearch.searchForIssuesUsingJql({ jql, diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index f5d1f9e60..2fbf32792 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -59,6 +59,8 @@ interface JiraConfig { interface JiraComment { id?: string; created?: string; + /** ISO 8601 last-updated timestamp. JIRA exposes this on the comment payload as `updated`. */ + updated?: string; body?: unknown; author?: { accountId?: string; displayName?: string; emailAddress?: string }; } @@ -73,6 +75,10 @@ interface JiraSearchIssue { labels?: string[]; subtasks?: JiraSubtask[]; attachment?: JiraAttachment[]; + /** ISO 8601 timestamp of issue creation. JIRA exposes this on `fields.created`. */ + created?: string; + /** ISO 8601 timestamp of last issue update. JIRA exposes this on `fields.updated`. */ + updated?: string; }; } @@ -116,6 +122,13 @@ export class JiraPMProvider implements PMProvider { ? resolveJiraMediaUrls(mediaRefs, attachments, 'description') : undefined; + // MNG-1422: JIRA returns `fields.created` / `fields.updated` as ISO + // timestamps when those fields are requested (the client requests them + // by default). Surface them as optional fields without altering + // existing behavior when the values are missing. + const created = (fields as { created?: string }).created; + const updated = (fields as { updated?: string }).updated; + return { id: issue.key ?? id, title: (fields.summary as string) ?? '', @@ -129,6 +142,8 @@ export class JiraPMProvider implements PMProvider { }), ), ...(inlineMedia !== undefined && inlineMedia.length > 0 ? { inlineMedia } : {}), + ...(created ? { createdAt: created } : {}), + ...(updated ? { updatedAt: updated } : {}), }; } @@ -143,6 +158,11 @@ export class JiraPMProvider implements PMProvider { name: c.author?.displayName ?? '', username: c.author?.emailAddress ?? '', }, + // JIRA comments carry both `created` and `updated` ISO timestamps. + // Preserve them on the normalized shape; fall back to `created` + // for `updatedAt` when JIRA hasn't recorded a separate edit. + ...(c.created ? { createdAt: c.created } : {}), + ...((c.updated ?? c.created) ? { updatedAt: c.updated ?? c.created } : {}), })); } @@ -230,6 +250,8 @@ export class JiraPMProvider implements PMProvider { labels: ((issue.fields?.labels as string[]) ?? []).map( (l: string): WorkItemLabel => ({ id: l, name: l }), ), + ...(issue.fields?.created ? { createdAt: issue.fields.created } : {}), + ...(issue.fields?.updated ? { updatedAt: issue.fields.updated } : {}), })); } diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index 2eccc8b85..d7ed8d144 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -154,6 +154,12 @@ export class LinearPMProvider implements PMProvider { }), ), inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Linear surfaces `createdAt` / `updatedAt` directly on the issue + // payload (see `LinearIssue` in `src/linear/types.ts`). Preserve + // empty-string sentinels as undefined to keep callers from + // branching on falsy strings. + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), }; } @@ -171,6 +177,8 @@ export class LinearPMProvider implements PMProvider { username: c.user?.email ?? '', }, inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), }; }); } @@ -217,6 +225,8 @@ export class LinearPMProvider implements PMProvider { description: issue.description ?? '', url: issue.url, labels: [], + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), }; } @@ -248,6 +258,8 @@ export class LinearPMProvider implements PMProvider { color: l.color, }), ), + ...(issue.createdAt ? { createdAt: issue.createdAt } : {}), + ...(issue.updatedAt ? { updatedAt: issue.updatedAt } : {}), })); } diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts index 4a57463f5..3a5143625 100644 --- a/src/pm/trello/adapter.ts +++ b/src/pm/trello/adapter.ts @@ -67,6 +67,11 @@ export class TrelloPMProvider implements PMProvider { }), ), inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Trello surfaces only `dateLastActivity` — map it to `updatedAt` + // when present. No `createdAt` is reliably available (cards expose + // only the most recent activity), so we leave it undefined rather + // than synthesising one. + ...(card.dateLastActivity ? { updatedAt: card.dateLastActivity } : {}), }; } @@ -84,6 +89,11 @@ export class TrelloPMProvider implements PMProvider { username: c.memberCreator.username, }, inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + // Trello's action `date` is the creation/edit time for the + // commentCard action. Surface it on both timestamp fields so + // downstream consumers don't need to re-encode the `date` + // field they already see. + ...(c.date ? { createdAt: c.date, updatedAt: c.date } : {}), }; }); } @@ -124,6 +134,12 @@ export class TrelloPMProvider implements PMProvider { color: l.color, }), ), + // New cards have a fresh `dateLastActivity`. Surface it on both + // timestamp fields — for a newly-created card the activity timestamp + // is effectively the creation timestamp. + ...(card.dateLastActivity + ? { createdAt: card.dateLastActivity, updatedAt: card.dateLastActivity } + : {}), }; } @@ -148,6 +164,7 @@ export class TrelloPMProvider implements PMProvider { color: l.color, }), ), + ...(card.dateLastActivity ? { updatedAt: card.dateLastActivity } : {}), })); } diff --git a/src/pm/types.ts b/src/pm/types.ts index 31aa233f1..95900b778 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -101,6 +101,20 @@ export interface WorkItem { labels: WorkItemLabel[]; /** Inline media references parsed from the work item description */ inlineMedia?: MediaReference[]; + /** + * ISO 8601 timestamp of when the work item was created, as reported by the + * provider. Optional because some providers (or legacy code paths) may not + * surface this. Downstream mutation-result helpers prefer this over + * synthetic timestamps — see `src/gadgets/pm/core/mutationResults.ts`. + */ + createdAt?: string; + /** + * ISO 8601 timestamp of the last provider-reported update. Optional for the + * same reasons as `createdAt`. Mutation-result helpers fall back to the + * current ISO timestamp only when the mutation was a synthetic no-op or + * aborted outcome — never to pretend a provider actually wrote new data. + */ + updatedAt?: string; } export interface WorkItemLabel { @@ -120,6 +134,20 @@ export interface WorkItemComment { }; /** Inline media references parsed from the comment text */ inlineMedia?: MediaReference[]; + /** + * ISO 8601 timestamp of when the comment was created, as reported by the + * provider. Optional — when present, mutation-result helpers prefer this + * over synthetic timestamps. Trello/JIRA derive this from the comment's + * `date`/`created` field; Linear surfaces it from `createdAt`. + */ + createdAt?: string; + /** + * ISO 8601 timestamp of the last provider-reported update on the comment. + * Optional. Linear exposes this distinctly from `createdAt`; Trello/JIRA + * may map this to the same value as `createdAt` when no edit history is + * surfaced. + */ + updatedAt?: string; } export interface Checklist { diff --git a/src/trello/client.ts b/src/trello/client.ts index 1064da0ed..80e6f9d81 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -81,6 +81,13 @@ export interface TrelloCard { shortUrl: string; idList: string; labels: Array<{ id: string; name: string; color: string }>; + /** + * Trello does not expose a true creation timestamp on cards; the closest + * provider field is `dateLastActivity` (last touched). We surface it as + * `dateLastActivity` so the PM adapter can wire it into `WorkItem.updatedAt` + * without pretending it's a creation marker. + */ + dateLastActivity?: string; } function mapCardResponse(card: { @@ -91,6 +98,7 @@ function mapCardResponse(card: { shortUrl?: string; idList?: string; labels?: unknown; + dateLastActivity?: string; }): TrelloCard { const labels = card.labels as Array<{ id?: string; name?: string; color?: string }> | undefined; return { @@ -101,6 +109,7 @@ function mapCardResponse(card: { shortUrl: card.shortUrl || '', idList: card.idList || '', labels: mapLabels(labels), + ...(card.dateLastActivity ? { dateLastActivity: card.dateLastActivity } : {}), }; } diff --git a/tests/helpers/fakePMProvider.ts b/tests/helpers/fakePMProvider.ts index 787c0d144..cc4bad724 100644 --- a/tests/helpers/fakePMProvider.ts +++ b/tests/helpers/fakePMProvider.ts @@ -116,6 +116,44 @@ function nextId(prefix: string): string { return `${prefix}-${_idCounter}`; } +/** + * Deterministic timestamp source used by the fake provider so unit tests can + * assert exact ISO strings without `vi.useFakeTimers()`. Tests can override + * the next timestamp returned via `setNextFakeTimestamp`. When no override + * is queued, the helper falls back to a monotonic stamp based on a fixed + * epoch so consecutive calls still produce stable, ordered values. + * + * MNG-1422: the mutation-result contracts pin `updatedAt` semantics; the fake + * must produce predictable provider timestamps so callers can verify the + * contract under both `'ok'` (provider stamp) and `'no-op'` (synthetic + * fallback) paths. + */ +const FAKE_EPOCH_MS = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00.000Z +let _timestampCounter = 0; +const _timestampOverrides: string[] = []; + +function nextFakeTimestamp(): string { + const override = _timestampOverrides.shift(); + if (override) return override; + _timestampCounter += 1; + return new Date(FAKE_EPOCH_MS + _timestampCounter * 1000).toISOString(); +} + +/** + * Queue a specific timestamp for the next provider write. Multiple queued + * values are consumed FIFO. Useful when a test needs to assert that a fresh + * provider write flows through to `WorkItem.updatedAt`. + */ +export function setNextFakeTimestamp(iso: string): void { + _timestampOverrides.push(iso); +} + +/** Reset the deterministic timestamp counter + queue. */ +export function resetFakeTimestamps(): void { + _timestampCounter = 0; + _timestampOverrides.length = 0; +} + // ── The provider implementation ───────────────────────────────────────── export function createFakePMProvider(): { provider: PMProvider; store: FakePMStore } { @@ -139,15 +177,19 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto if (!item) throw new Error(`Fake work item '${id}' not found`); if (updates.title !== undefined) item.title = updates.title; if (updates.description !== undefined) item.description = updates.description; + item.updatedAt = nextFakeTimestamp(); }, async addComment(id, text): Promise { const commentId = nextId('comment'); + const timestamp = nextFakeTimestamp(); const comment: WorkItemComment = { id: commentId, - date: new Date().toISOString(), + date: timestamp, text, author: { id: 'fake-user', name: 'Fake User', username: 'fake' }, + createdAt: timestamp, + updatedAt: timestamp, }; const list = store.comments.get(id) ?? []; list.push(comment); @@ -160,6 +202,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto const comment = list.find((c) => c.id === commentId); if (!comment) throw new Error(`Fake comment '${commentId}' not found on '${id}'`); comment.text = text; + comment.updatedAt = nextFakeTimestamp(); }, async createWorkItem(config): Promise { @@ -168,6 +211,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto if (!container) throw new Error(`Fake container '${containerId}' not found`); const id = nextId('item'); + const timestamp = nextFakeTimestamp(); const labels: WorkItemLabel[] = (config.labels ?? []).map((raw) => { const labelId = parseLabelId(raw); const existing = store.labels.get(labelId); @@ -183,6 +227,8 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto status: 'Todo', labels, containerId, + createdAt: timestamp, + updatedAt: timestamp, }; store.workItems.set(id, workItem); container.workItemIds.add(id); @@ -238,6 +284,7 @@ export function createFakePMProvider(): { provider: PMProvider; store: FakePMSto // throw on test-provided values that aren't in the store. item.status = branded; } + item.updatedAt = nextFakeTimestamp(); }, async addLabel(id, labelIdOrName): Promise { diff --git a/tests/helpers/mockPMProvider.ts b/tests/helpers/mockPMProvider.ts index 51f2220da..d94458f1b 100644 --- a/tests/helpers/mockPMProvider.ts +++ b/tests/helpers/mockPMProvider.ts @@ -2,6 +2,75 @@ import { vi } from 'vitest'; import type { MediaReference } from '../../src/pm/types.js'; +/** + * Stable deterministic timestamp the mock provider stamps onto generated + * fixtures (work items, comments). Tests that need to assert exact + * timestamps can reach for this constant rather than calling + * `new Date().toISOString()` (which changes per test run). + * + * MNG-1422: introduced alongside the mutation-result contracts so tests can + * verify provider timestamps flow through to `WorkItem.updatedAt` / + * `WorkItemComment.updatedAt` without timer mocking. + */ +export const MOCK_PROVIDER_TIMESTAMP = '2026-01-01T00:00:00.000Z'; + +/** + * Build a minimal WorkItem fixture with deterministic timestamps. Provided + * as a sibling helper to `createMockPMProvider` so callers stamping + * `getWorkItem.mockResolvedValue(...)` don't have to remember the field + * names. Override the returned object as needed. + */ +export function createMockWorkItem( + overrides?: Partial<{ + id: string; + title: string; + description: string; + url: string; + status: string; + statusId: string; + labels: Array<{ id: string; name: string; color?: string }>; + inlineMedia: MediaReference[]; + createdAt: string; + updatedAt: string; + }>, +) { + return { + id: 'mock-item-1', + title: 'Mock work item', + description: '', + url: 'mock://workitem/mock-item-1', + labels: [], + createdAt: MOCK_PROVIDER_TIMESTAMP, + updatedAt: MOCK_PROVIDER_TIMESTAMP, + ...overrides, + }; +} + +/** + * Build a minimal WorkItemComment fixture with deterministic timestamps. + */ +export function createMockWorkItemComment( + overrides?: Partial<{ + id: string; + date: string; + text: string; + author: { id: string; name: string; username: string }; + inlineMedia: MediaReference[]; + createdAt: string; + updatedAt: string; + }>, +) { + return { + id: 'mock-comment-1', + date: MOCK_PROVIDER_TIMESTAMP, + text: '', + author: { id: 'mock-user', name: 'Mock User', username: 'mock' }, + createdAt: MOCK_PROVIDER_TIMESTAMP, + updatedAt: MOCK_PROVIDER_TIMESTAMP, + ...overrides, + }; +} + /** * Creates a mock PMProvider with all methods stubbed as vi.fn(). * Use this factory instead of copy-pasting the mock object in every test file. @@ -24,6 +93,10 @@ import type { MediaReference } from '../../src/pm/types.js'; * inlineMedia: [{ url: '...', mimeType: 'image/png', source: 'description' }], * }); * ``` + * + * Companion helpers `createMockWorkItem` / `createMockWorkItemComment` stamp + * deterministic `createdAt` / `updatedAt` values from `MOCK_PROVIDER_TIMESTAMP` + * — use them when asserting on the new optional timestamp fields (MNG-1422). */ export function createMockPMProvider() { return { @@ -40,6 +113,8 @@ export function createMockPMProvider() { text: string; author: { id: string; name: string; username: string }; inlineMedia?: MediaReference[]; + createdAt?: string; + updatedAt?: string; }> > >(), diff --git a/tests/unit/gadgets/github/core/mutationResults.test.ts b/tests/unit/gadgets/github/core/mutationResults.test.ts new file mode 100644 index 000000000..e966a6a46 --- /dev/null +++ b/tests/unit/gadgets/github/core/mutationResults.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + abortedResult, + currentTimestamp, + type GitHubMutationResult, + noOpResult, + okResult, + pickTimestamp, +} from '../../../../../src/gadgets/github/core/mutationResults.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('GitHub mutationResults', () => { + describe('currentTimestamp', () => { + it('returns the current ISO timestamp', () => { + expect(currentTimestamp()).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('pickTimestamp', () => { + it('prefers the GitHub-supplied timestamp', () => { + expect(pickTimestamp('2025-12-01T01:02:03Z')).toBe('2025-12-01T01:02:03Z'); + }); + + it('falls back to the current ISO timestamp when undefined', () => { + expect(pickTimestamp(undefined)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is null', () => { + expect(pickTimestamp(null)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is the empty string', () => { + expect(pickTimestamp('')).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('okResult', () => { + it('preserves the GitHub timestamp and stringifies numeric ids', () => { + const result: GitHubMutationResult = okResult({ + id: 4242, + updatedAt: '2025-12-01T01:02:03Z', + url: 'https://github.com/o/r/pull/42', + }); + expect(result).toEqual({ + id: '4242', + status: 'ok', + updatedAt: '2025-12-01T01:02:03Z', + url: 'https://github.com/o/r/pull/42', + }); + }); + + it('accepts string ids unchanged', () => { + const result = okResult({ id: 'gh-comment-id', updatedAt: '2025-12-01T01:02:03Z' }); + expect(result.id).toBe('gh-comment-id'); + }); + + it('rejects an empty GitHub timestamp', () => { + expect(() => okResult({ id: 1, updatedAt: '' })).toThrow( + 'okResult requires a GitHub-supplied updatedAt timestamp', + ); + }); + + it('rejects a missing GitHub timestamp at runtime', () => { + expect(() => okResult({ id: 1 } as Parameters[0])).toThrow( + 'okResult requires a GitHub-supplied updatedAt timestamp', + ); + }); + + it('omits optional fields when not provided', () => { + const result = okResult({ id: 1, updatedAt: '2025-12-01T01:02:03Z' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('noOpResult', () => { + it('uses the current ISO timestamp', () => { + const result = noOpResult({ + id: 99, + message: 'PR already exists for this branch', + url: 'https://github.com/o/r/pull/99', + }); + expect(result).toEqual({ + id: '99', + status: 'no-op', + updatedAt: FROZEN_NOW.toISOString(), + url: 'https://github.com/o/r/pull/99', + message: 'PR already exists for this branch', + }); + }); + }); + + describe('abortedResult', () => { + it('uses the current ISO timestamp', () => { + const result = abortedResult({ + id: 'comment-1', + message: 'expected author mismatch', + }); + expect(result).toEqual({ + id: 'comment-1', + status: 'aborted', + updatedAt: FROZEN_NOW.toISOString(), + message: 'expected author mismatch', + }); + }); + + it('omits optional fields when not provided', () => { + const result = abortedResult({ id: 1 }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('status union exhaustiveness', () => { + it('covers every GitHubMutationStatus member', () => { + const ok = okResult({ id: 1, updatedAt: '2025-12-01T00:00:00Z' }); + const noop = noOpResult({ id: 2 }); + const aborted = abortedResult({ id: 3 }); + expect(new Set([ok.status, noop.status, aborted.status])).toEqual( + new Set(['ok', 'no-op', 'aborted']), + ); + }); + }); +}); diff --git a/tests/unit/gadgets/pm/core/mutationResults.test.ts b/tests/unit/gadgets/pm/core/mutationResults.test.ts new file mode 100644 index 000000000..46fcc46fd --- /dev/null +++ b/tests/unit/gadgets/pm/core/mutationResults.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + abortedResult, + currentTimestamp, + noOpResult, + okResult, + type PMMutationResult, + pickTimestamp, +} from '../../../../../src/gadgets/pm/core/mutationResults.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('PM mutationResults', () => { + describe('currentTimestamp', () => { + it('returns the current ISO timestamp', () => { + expect(currentTimestamp()).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('pickTimestamp', () => { + it('prefers the provider timestamp when present', () => { + expect(pickTimestamp('2025-12-01T01:02:03.000Z')).toBe('2025-12-01T01:02:03.000Z'); + }); + + it('falls back to the current ISO timestamp when undefined', () => { + expect(pickTimestamp(undefined)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is null', () => { + expect(pickTimestamp(null)).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back when the provider value is the empty string', () => { + expect(pickTimestamp('')).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('okResult', () => { + it('uses the provider timestamp on the ok result', () => { + const result: PMMutationResult = okResult({ + id: 'item-1', + updatedAt: '2025-12-01T01:02:03.000Z', + url: 'https://trello.com/c/item-1', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'ok', + updatedAt: '2025-12-01T01:02:03.000Z', + url: 'https://trello.com/c/item-1', + }); + }); + + it('rejects an empty provider timestamp', () => { + expect(() => okResult({ id: 'item-1', updatedAt: '' })).toThrow( + 'okResult requires a provider-supplied updatedAt timestamp', + ); + }); + + it('rejects a missing provider timestamp at runtime', () => { + expect(() => okResult({ id: 'item-1' } as Parameters[0])).toThrow( + 'okResult requires a provider-supplied updatedAt timestamp', + ); + }); + + it('omits optional fields when not provided', () => { + const result = okResult({ id: 'item-1', updatedAt: '2025-12-01T01:02:03.000Z' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + + it('includes the message when provided', () => { + const result = okResult({ + id: 'item-1', + updatedAt: '2025-12-01T01:02:03.000Z', + message: 'Updated title', + }); + expect(result.message).toBe('Updated title'); + }); + }); + + describe('noOpResult', () => { + it('uses the current ISO timestamp', () => { + const result = noOpResult({ + id: 'item-1', + message: 'already in destination state', + url: 'https://trello.com/c/item-1', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'no-op', + updatedAt: FROZEN_NOW.toISOString(), + url: 'https://trello.com/c/item-1', + message: 'already in destination state', + }); + }); + + it('never accepts a provider timestamp (synthetic outcome)', () => { + // Type-level guard: noOpResult signature omits updatedAt. Calling + // with one would be a compile error. This test pins the runtime + // behavior — the result always carries the current ISO timestamp. + const result = noOpResult({ id: 'item-1' }); + expect(result.updatedAt).toBe(FROZEN_NOW.toISOString()); + }); + }); + + describe('abortedResult', () => { + it('uses the current ISO timestamp', () => { + const result = abortedResult({ + id: 'item-1', + message: 'expected source state mismatch', + }); + expect(result).toEqual({ + id: 'item-1', + status: 'aborted', + updatedAt: FROZEN_NOW.toISOString(), + message: 'expected source state mismatch', + }); + }); + + it('omits optional fields when not provided', () => { + const result = abortedResult({ id: 'item-1' }); + expect(result.url).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + }); + + describe('status union exhaustiveness', () => { + // This test is a discriminator-coverage guard: it pins that the three + // helper builders cover every status union member. Adding a new + // status without a matching helper here surfaces as an unused + // literal at compile time. + it('covers every PMMutationStatus member', () => { + const ok = okResult({ id: 'a', updatedAt: '2025-12-01T00:00:00.000Z' }); + const noop = noOpResult({ id: 'b' }); + const aborted = abortedResult({ id: 'c' }); + const statuses: Array<'ok' | 'no-op' | 'aborted'> = [ok.status, noop.status, aborted.status]; + expect(new Set(statuses)).toEqual(new Set(['ok', 'no-op', 'aborted'])); + }); + }); +}); diff --git a/tests/unit/integrations/pm-fake-lifecycle.test.ts b/tests/unit/integrations/pm-fake-lifecycle.test.ts index b471692ca..c0f67dab1 100644 --- a/tests/unit/integrations/pm-fake-lifecycle.test.ts +++ b/tests/unit/integrations/pm-fake-lifecycle.test.ts @@ -10,13 +10,19 @@ * they opt into `manifest.lifecycle.enabled = true` in plans 2, 3, 4. */ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { createFakePMManifest, createFakePMProvider, + resetFakeTimestamps, runLifecycleScenario, + setNextFakeTimestamp, } from '../../helpers/fakePMProvider.js'; +beforeEach(() => { + resetFakeTimestamps(); +}); + describe('FakePMProvider — lifecycle', () => { it('createFakePMProvider returns a typed PMProvider wired to an in-memory store', () => { const { provider, store } = createFakePMProvider(); @@ -136,4 +142,87 @@ describe('FakePMProvider — lifecycle', () => { const parsed2 = schema.parse(JSON.parse(JSON.stringify(parsed1))); expect(parsed2).toEqual(parsed1); }); + + describe('deterministic timestamps (MNG-1422)', () => { + it('stamps createWorkItem with provider-shaped createdAt and updatedAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + setNextFakeTimestamp('2026-01-15T00:00:00.000Z'); + const created = await provider.createWorkItem({ + containerId, + title: 'Stamped', + }); + + expect(created.createdAt).toBe('2026-01-15T00:00:00.000Z'); + expect(created.updatedAt).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('bumps updatedAt on subsequent updateWorkItem without altering createdAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + setNextFakeTimestamp('2026-01-01T00:00:00.000Z'); + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-02-01T00:00:00.000Z'); + await provider.updateWorkItem(created.id, { title: 'Renamed' }); + + const reloaded = await provider.getWorkItem(created.id); + expect(reloaded.createdAt).toBe('2026-01-01T00:00:00.000Z'); + expect(reloaded.updatedAt).toBe('2026-02-01T00:00:00.000Z'); + }); + + it('stamps addComment with deterministic createdAt and updatedAt', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-03-01T00:00:00.000Z'); + const commentId = await provider.addComment(created.id, 'hello'); + + const comments = await provider.getWorkItemComments(created.id); + const c = comments.find((x) => x.id === commentId); + expect(c?.createdAt).toBe('2026-03-01T00:00:00.000Z'); + expect(c?.updatedAt).toBe('2026-03-01T00:00:00.000Z'); + }); + + it('updates comment.updatedAt without changing createdAt on subsequent edit', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const created = await provider.createWorkItem({ containerId, title: 'Item' }); + + setNextFakeTimestamp('2026-03-01T00:00:00.000Z'); + const commentId = await provider.addComment(created.id, 'hello'); + + setNextFakeTimestamp('2026-04-01T00:00:00.000Z'); + await provider.updateComment(created.id, commentId, 'edited'); + + const c = (await provider.getWorkItemComments(created.id)).find((x) => x.id === commentId); + expect(c?.createdAt).toBe('2026-03-01T00:00:00.000Z'); + expect(c?.updatedAt).toBe('2026-04-01T00:00:00.000Z'); + }); + + it('falls back to monotonic synthetic stamps when no override is queued', async () => { + const { provider, store } = createFakePMProvider(); + const containerId = Array.from(store.containers.keys())[0]; + if (!containerId) throw new Error('fake provider initialised without containers'); + + const a = await provider.createWorkItem({ containerId, title: 'First' }); + const b = await provider.createWorkItem({ containerId, title: 'Second' }); + + expect(a.createdAt).toBeTruthy(); + expect(b.createdAt).toBeTruthy(); + // Synthetic stamps are strictly monotonic — second is later than first. + expect(new Date(b.createdAt ?? '').getTime()).toBeGreaterThan( + new Date(a.createdAt ?? '').getTime(), + ); + }); + }); }); diff --git a/tests/unit/integrations/pm-mock-provider-timestamps.test.ts b/tests/unit/integrations/pm-mock-provider-timestamps.test.ts new file mode 100644 index 000000000..ab062945f --- /dev/null +++ b/tests/unit/integrations/pm-mock-provider-timestamps.test.ts @@ -0,0 +1,68 @@ +/** + * Companion tests for the mock-provider timestamp helpers introduced in + * MNG-1422. These guard the deterministic-fixture contract: tests that need + * to assert provider timestamps flow through to mutation-result helpers must + * be able to do so without timer mocking. + */ + +import { describe, expect, it } from 'vitest'; + +import { + createMockPMProvider, + createMockWorkItem, + createMockWorkItemComment, + MOCK_PROVIDER_TIMESTAMP, +} from '../../helpers/mockPMProvider.js'; + +describe('mockPMProvider — deterministic timestamps (MNG-1422)', () => { + it('MOCK_PROVIDER_TIMESTAMP is a stable ISO string', () => { + expect(MOCK_PROVIDER_TIMESTAMP).toBe('2026-01-01T00:00:00.000Z'); + // Sanity check: parses back to a valid Date. + expect(Number.isNaN(new Date(MOCK_PROVIDER_TIMESTAMP).getTime())).toBe(false); + }); + + it('createMockWorkItem produces a work item with stable timestamps', () => { + const item = createMockWorkItem(); + expect(item.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(item.updatedAt).toBe(MOCK_PROVIDER_TIMESTAMP); + }); + + it('createMockWorkItem accepts overrides', () => { + const item = createMockWorkItem({ + id: 'custom-id', + updatedAt: '2026-05-01T00:00:00.000Z', + }); + expect(item.id).toBe('custom-id'); + expect(item.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); // unchanged default + expect(item.updatedAt).toBe('2026-05-01T00:00:00.000Z'); + }); + + it('createMockWorkItemComment produces a comment with stable timestamps', () => { + const comment = createMockWorkItemComment(); + expect(comment.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.updatedAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.date).toBe(MOCK_PROVIDER_TIMESTAMP); + }); + + it('createMockWorkItemComment accepts overrides', () => { + const comment = createMockWorkItemComment({ + id: 'c-99', + text: 'override text', + updatedAt: '2026-06-01T00:00:00.000Z', + }); + expect(comment.id).toBe('c-99'); + expect(comment.text).toBe('override text'); + expect(comment.createdAt).toBe(MOCK_PROVIDER_TIMESTAMP); + expect(comment.updatedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('createMockPMProvider still exposes every vi.fn() stub the prior contract listed', () => { + const mock = createMockPMProvider(); + // Spot-check a handful of methods that pre-date MNG-1422 so the + // timestamp addition does not silently regress the surface. + expect(typeof mock.getWorkItem).toBe('function'); + expect(typeof mock.addComment).toBe('function'); + expect(typeof mock.moveWorkItem).toBe('function'); + expect(typeof mock.createWorkItem).toBe('function'); + }); +}); diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index a7b70665b..a2141e4c4 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -188,6 +188,42 @@ describe('JiraPMProvider', () => { ); expect(result.inlineMedia).toEqual(resolvedMedia); }); + + it('preserves JIRA fields.created and fields.updated as work-item timestamps', async () => { + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-301', + fields: { + summary: 'Timestamped issue', + description: { type: 'doc' }, + status: { name: 'To Do' }, + labels: [], + created: '2026-04-01T08:00:00.000Z', + updated: '2026-04-15T09:30:00.000Z', + }, + }); + + const result = await provider.getWorkItem('PROJ-301'); + + expect(result.createdAt).toBe('2026-04-01T08:00:00.000Z'); + expect(result.updatedAt).toBe('2026-04-15T09:30:00.000Z'); + }); + + it('leaves createdAt and updatedAt undefined when JIRA omits them', async () => { + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-302', + fields: { + summary: 'No timestamps', + description: { type: 'doc' }, + status: { name: 'Done' }, + labels: [], + }, + }); + + const result = await provider.getWorkItem('PROJ-302'); + + expect(result.createdAt).toBeUndefined(); + expect(result.updatedAt).toBeUndefined(); + }); }); describe('getWorkItemComments', () => { @@ -218,6 +254,10 @@ describe('JiraPMProvider', () => { name: 'Alice', username: 'alice@example.com', }, + // MNG-1422: JIRA comments expose `created`; absent `updated` + // falls back to `created` so consumers always see a value. + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', }, ]); }); @@ -238,6 +278,26 @@ describe('JiraPMProvider', () => { ]); }); + it('uses comment `updated` when present, falling back to `created` otherwise', async () => { + mockAdfToPlainText.mockReturnValue('Edited text'); + mockJiraClient.getIssueComments.mockResolvedValue([ + { + id: 'c-edit', + created: '2024-01-01T00:00:00.000Z', + updated: '2024-01-05T00:00:00.000Z', + body: { type: 'doc' }, + author: { accountId: 'u', displayName: 'A', emailAddress: 'a@example.com' }, + }, + ]); + + const result = await provider.getWorkItemComments('PROJ-123'); + + expect(result[0]).toMatchObject({ + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + }); + }); + it('does not include inlineMedia on comments (comment media resolution is not supported)', async () => { mockJiraClient.getIssueComments.mockResolvedValue([ { @@ -477,6 +537,28 @@ describe('JiraPMProvider', () => { ); }); }); + + it('preserves JIRA timestamps on listed items', async () => { + mockJiraClient.searchIssues.mockResolvedValue([ + { + key: 'PROJ-T', + fields: { + summary: 'Timestamped', + status: { name: 'To Do' }, + labels: [], + created: '2026-04-01T08:00:00.000Z', + updated: '2026-04-15T09:30:00.000Z', + }, + }, + ]); + + const result = await provider.listWorkItems('PROJ'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-04-01T08:00:00.000Z', + updatedAt: '2026-04-15T09:30:00.000Z', + }); + }); }); describe('moveWorkItem', () => { diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts index 7639d9596..7978117fb 100644 --- a/tests/unit/pm/linear/adapter.test.ts +++ b/tests/unit/pm/linear/adapter.test.ts @@ -179,6 +179,33 @@ describe('LinearPMProvider', () => { const result = await provider.getWorkItem('issue-uuid'); expect(result.inlineMedia).toBeUndefined(); }); + + it('preserves Linear createdAt and updatedAt on the work item', async () => { + mockGetIssue.mockResolvedValue( + makeIssue({ + createdAt: '2026-04-01T08:00:00Z', + updatedAt: '2026-04-15T09:30:00Z', + }), + ); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.createdAt).toBe('2026-04-01T08:00:00Z'); + expect(result.updatedAt).toBe('2026-04-15T09:30:00Z'); + }); + + it('leaves createdAt/updatedAt undefined when Linear returns empty strings', async () => { + // LinearClient normalizes missing timestamps to '' — the adapter + // treats empties as "no provider value" so downstream mutation- + // result helpers fall through cleanly instead of round-tripping + // empty strings. + mockGetIssue.mockResolvedValue(makeIssue({ createdAt: '', updatedAt: '' })); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.createdAt).toBeUndefined(); + expect(result.updatedAt).toBeUndefined(); + }); }); // ========================================================================= @@ -279,6 +306,26 @@ describe('LinearPMProvider', () => { expect(result[0].inlineMedia).toBeUndefined(); }); + + it('preserves Linear createdAt and updatedAt on comments', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c-ts', + body: 'Timestamped', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + issueId: 'issue-uuid', + user: null, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + }); + }); }); // ========================================================================= diff --git a/tests/unit/pm/trello/adapter.test.ts b/tests/unit/pm/trello/adapter.test.ts index c339777b2..5c75c3091 100644 --- a/tests/unit/pm/trello/adapter.test.ts +++ b/tests/unit/pm/trello/adapter.test.ts @@ -151,6 +151,40 @@ describe('TrelloPMProvider', () => { expect(result.inlineMedia).toBeUndefined(); }); + + it('preserves Trello dateLastActivity as updatedAt when present', async () => { + mockTrelloClient.getCard.mockResolvedValue({ + id: 'card-7', + name: 'With activity', + desc: '', + url: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [], + dateLastActivity: '2026-04-01T12:00:00.000Z', + }); + + const result = await provider.getWorkItem('card-7'); + + expect(result.updatedAt).toBe('2026-04-01T12:00:00.000Z'); + // Trello cards don't expose a true creation timestamp — leave + // `createdAt` undefined rather than synthesising one. + expect(result.createdAt).toBeUndefined(); + }); + + it('omits updatedAt when Trello does not surface dateLastActivity', async () => { + mockTrelloClient.getCard.mockResolvedValue({ + id: 'card-8', + name: 'No activity', + desc: '', + url: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [], + }); + + const result = await provider.getWorkItem('card-8'); + + expect(result.updatedAt).toBeUndefined(); + }); }); describe('getWorkItemComments', () => { @@ -174,6 +208,10 @@ describe('TrelloPMProvider', () => { text: 'Hello world', author: { id: 'member-1', name: 'Alice', username: 'alice' }, inlineMedia: undefined, + // MNG-1422: Trello action `date` is preserved on both + // timestamp fields. + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', }, ]); }); @@ -259,6 +297,24 @@ describe('TrelloPMProvider', () => { expect(ref.source).toBe('comment'); } }); + + it('preserves comment date as createdAt and updatedAt', async () => { + mockTrelloClient.getCardComments.mockResolvedValue([ + { + id: 'comment-ts', + date: '2026-05-01T10:00:00.000Z', + data: { text: 'a comment' }, + memberCreator: { id: 'm1', fullName: 'Alice', username: 'alice' }, + }, + ]); + + const result = await provider.getWorkItemComments('card-1'); + + expect(result[0]).toMatchObject({ + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:00:00.000Z', + }); + }); }); describe('updateWorkItem', () => { @@ -318,6 +374,25 @@ describe('TrelloPMProvider', () => { }); expect(result).toMatchObject({ id: 'new-card', title: 'New Feature' }); }); + + it('uses Trello dateLastActivity as both createdAt and updatedAt on a fresh card', async () => { + mockTrelloClient.createCard.mockResolvedValue({ + id: 'fresh-card', + name: 'Fresh', + desc: '', + url: 'https://trello.com/c/fresh', + labels: [], + dateLastActivity: '2026-04-01T12:00:00.000Z', + }); + + const result = await provider.createWorkItem({ + containerId: 'list-1', + title: 'Fresh', + }); + + expect(result.createdAt).toBe('2026-04-01T12:00:00.000Z'); + expect(result.updatedAt).toBe('2026-04-01T12:00:00.000Z'); + }); }); describe('listWorkItems', () => { From da880021279116818dafe0fb4567c4eb01645692 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 1 Jun 2026 15:00:11 +0200 Subject: [PATCH 02/25] feat(scm): structured outputs for PR comment/reply/update/review mutations (MNG-1425) (#1387) Co-authored-by: Cascade Bot --- src/gadgets/github/PostPRComment.ts | 18 ++- src/gadgets/github/ReplyToReviewComment.ts | 20 +-- src/gadgets/github/UpdatePRComment.ts | 18 ++- src/gadgets/github/core/createPRReview.ts | 42 ++++++- src/gadgets/github/core/postPRComment.ts | 38 ++++-- .../github/core/replyToReviewComment.ts | 35 ++++-- src/gadgets/github/core/updatePRComment.ts | 44 +++++-- src/github/client.ts | 54 ++++++++- tests/unit/cli/scm/scm-commands.test.ts | 81 +++++++++++++ tests/unit/gadgets/github/core/misc.test.ts | 114 +++++++++++++----- .../gadgets/github/core/postPRComment.test.ts | 48 ++++++-- .../github/core/replyToReviewComment.test.ts | 45 +++++-- .../github/core/updatePRComment.test.ts | 50 ++++++-- .../gadgets/github/createPRReview.test.ts | 26 ++-- tests/unit/github/client.test.ts | 60 ++++++++- 15 files changed, 577 insertions(+), 116 deletions(-) diff --git a/src/gadgets/github/PostPRComment.ts b/src/gadgets/github/PostPRComment.ts index 1da480aaf..8638a0f1c 100644 --- a/src/gadgets/github/PostPRComment.ts +++ b/src/gadgets/github/PostPRComment.ts @@ -1,12 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { postPRComment } from './core/postPRComment.js'; import { postPRCommentDef } from './definitions.js'; export const PostPRComment = createGadgetClass(postPRCommentDef, async (params) => { - return postPRComment( - params.owner as string, - params.repo as string, - params.prNumber as number, - params.body as string, - ); + try { + const result = await postPRComment( + params.owner as string, + params.repo as string, + params.prNumber as number, + params.body as string, + ); + return `Comment posted (id: ${result.id}): ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('posting PR comment', error); + } }); diff --git a/src/gadgets/github/ReplyToReviewComment.ts b/src/gadgets/github/ReplyToReviewComment.ts index 72eaf9a03..d49bf2e55 100644 --- a/src/gadgets/github/ReplyToReviewComment.ts +++ b/src/gadgets/github/ReplyToReviewComment.ts @@ -1,13 +1,19 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { replyToReviewComment } from './core/replyToReviewComment.js'; import { replyToReviewCommentDef } from './definitions.js'; export const ReplyToReviewComment = createGadgetClass(replyToReviewCommentDef, async (params) => { - return replyToReviewComment( - params.owner as string, - params.repo as string, - params.prNumber as number, - params.commentId as number, - params.body as string, - ); + try { + const result = await replyToReviewComment( + params.owner as string, + params.repo as string, + params.prNumber as number, + params.commentId as number, + params.body as string, + ); + return `Reply posted successfully: ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('replying to comment', error); + } }); diff --git a/src/gadgets/github/UpdatePRComment.ts b/src/gadgets/github/UpdatePRComment.ts index e500cb28c..68d8e9b80 100644 --- a/src/gadgets/github/UpdatePRComment.ts +++ b/src/gadgets/github/UpdatePRComment.ts @@ -1,12 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updatePRComment } from './core/updatePRComment.js'; import { updatePRCommentDef } from './definitions.js'; export const UpdatePRComment = createGadgetClass(updatePRCommentDef, async (params) => { - return updatePRComment( - params.owner as string, - params.repo as string, - params.commentId as number, - params.body as string, - ); + try { + const result = await updatePRComment( + params.owner as string, + params.repo as string, + params.commentId as number, + params.body as string, + ); + return `Comment updated (id: ${result.id}): ${result.url ?? ''}`; + } catch (error) { + return formatGadgetError('updating PR comment', error); + } }); diff --git a/src/gadgets/github/core/createPRReview.ts b/src/gadgets/github/core/createPRReview.ts index b76ef5e64..3d752b92a 100644 --- a/src/gadgets/github/core/createPRReview.ts +++ b/src/gadgets/github/core/createPRReview.ts @@ -1,5 +1,6 @@ import { githubClient } from '../../../github/client.js'; import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; +import { type GitHubMutationResult, okResult, pickTimestamp } from './mutationResults.js'; export interface CreatePRReviewParams { owner: string; @@ -10,9 +11,28 @@ export interface CreatePRReviewParams { comments?: Array<{ path: string; line?: number; body: string }>; } -export interface CreatePRReviewResult { +/** + * Structured result returned by `createPRReview`. Extends + * `GitHubMutationResult` with review-specific context — `reviewUrl` (alias of + * `url` preserved for back-compat with the existing sidecar shape and + * `recordReviewSubmission` callers), `event` (the requested action, + * `APPROVE` / `REQUEST_CHANGES` / `COMMENT`), the PR identity, the + * `submittedAt` timestamp, and the inline-comment count (zero when the + * caller didn't pass any inline comments). + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope; the gadget wrapper formats them for the + * agent tool-result channel. + */ +export interface CreatePRReviewResult extends GitHubMutationResult { + /** Alias of `url`, retained because existing sidecar/session-state code reads `reviewUrl`. */ reviewUrl: string; - event: string; + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; + repoFullName: string; + prNumber: number; + /** GitHub-supplied `submitted_at`. Provider-supplied for submitted reviews. */ + submittedAt: string; + inlineCommentCount: number; } export async function createPRReview(params: CreatePRReviewParams): Promise { @@ -27,5 +47,21 @@ export async function createPRReview(params: CreatePRReviewParams): Promise { - try { - const runLinkFooter = buildRunLinkFooterFromEnv(); - const fullBody = runLinkFooter ? body + runLinkFooter : body; - const result = await githubClient.createPRComment(owner, repo, prNumber, fullBody); - return `Comment posted (id: ${result.id}): ${result.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error posting PR comment: ${message}`; - } +): Promise { + const runLinkFooter = buildRunLinkFooterFromEnv(); + const fullBody = runLinkFooter ? body + runLinkFooter : body; + const created = await githubClient.createPRComment(owner, repo, prNumber, fullBody); + return { + ...okResult({ + id: created.id, + updatedAt: created.updatedAt, + url: created.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber, + }; } diff --git a/src/gadgets/github/core/replyToReviewComment.ts b/src/gadgets/github/core/replyToReviewComment.ts index 031490343..c301748bf 100644 --- a/src/gadgets/github/core/replyToReviewComment.ts +++ b/src/gadgets/github/core/replyToReviewComment.ts @@ -1,4 +1,20 @@ import { githubClient } from '../../../github/client.js'; +import { type GitHubMutationResult, okResult, pickTimestamp } from './mutationResults.js'; + +/** + * Structured result returned by `replyToReviewComment`. Extends + * `GitHubMutationResult` with the parent-PR identity (`repoFullName`, + * `prNumber`). The reply's `updatedAt` is preferred from GitHub's response; + * we fall back to `createdAt` because some Octokit response shapes omit + * `updated_at` on freshly-created review-comment replies. + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope. + */ +export interface ReplyToReviewCommentResult extends GitHubMutationResult { + repoFullName: string; + prNumber: number; +} export async function replyToReviewComment( owner: string, @@ -6,12 +22,15 @@ export async function replyToReviewComment( prNumber: number, commentId: number, body: string, -): Promise { - try { - const reply = await githubClient.replyToReviewComment(owner, repo, prNumber, commentId, body); - return `Reply posted successfully: ${reply.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error replying to comment: ${message}`; - } +): Promise { + const reply = await githubClient.replyToReviewComment(owner, repo, prNumber, commentId, body); + return { + ...okResult({ + id: reply.id, + updatedAt: pickTimestamp(reply.updatedAt ?? reply.createdAt), + url: reply.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber, + }; } diff --git a/src/gadgets/github/core/updatePRComment.ts b/src/gadgets/github/core/updatePRComment.ts index 472b65a4b..56b6fbe76 100644 --- a/src/gadgets/github/core/updatePRComment.ts +++ b/src/gadgets/github/core/updatePRComment.ts @@ -1,16 +1,44 @@ import { githubClient } from '../../../github/client.js'; +import { type GitHubMutationResult, okResult } from './mutationResults.js'; + +/** + * Structured result returned by `updatePRComment`. Extends + * `GitHubMutationResult` with the parent-PR identity (`repoFullName`, + * `prNumber`). `prNumber` is included for parity with `postPRComment` and + * `replyToReviewComment`; we recover it from the comment URL because the + * issue-comment update API doesn't echo the issue number on the response. + * + * Failures throw (no prose sentinels). The CLI factory wraps thrown errors in + * the spec-014 `runtime` envelope. + */ +export interface UpdatePRCommentResult extends GitHubMutationResult { + repoFullName: string; + prNumber: number | null; +} + +const PR_NUMBER_FROM_HTML_URL_REGEX = /\/pull\/(\d+)/; + +function extractPRNumberFromHtmlUrl(htmlUrl: string): number | null { + const match = htmlUrl.match(PR_NUMBER_FROM_HTML_URL_REGEX); + if (!match) return null; + const n = Number.parseInt(match[1], 10); + return Number.isFinite(n) ? n : null; +} export async function updatePRComment( owner: string, repo: string, commentId: number, body: string, -): Promise { - try { - const result = await githubClient.updatePRComment(owner, repo, commentId, body); - return `Comment updated (id: ${result.id}): ${result.htmlUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error updating PR comment: ${message}`; - } +): Promise { + const updated = await githubClient.updatePRComment(owner, repo, commentId, body); + return { + ...okResult({ + id: updated.id, + updatedAt: updated.updatedAt, + url: updated.htmlUrl, + }), + repoFullName: `${owner}/${repo}`, + prNumber: extractPRNumberFromHtmlUrl(updated.htmlUrl), + }; } diff --git a/src/github/client.ts b/src/github/client.ts index 41dc3f217..b397ffa9f 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -43,9 +43,31 @@ export interface PRReviewComment { login: string; }; createdAt: string; + /** + * GitHub-supplied timestamp for the last update of the comment. Optional — + * only present on writes (createReplyForReviewComment) where GitHub returns + * `updated_at` alongside the new comment. Read paths + * (`listReviewComments`) don't surface it because the consumer doesn't need + * it for context shaping. + */ + updatedAt?: string; inReplyToId?: number; } +/** + * Result shape for issue-comment write mutations (`createPRComment`, + * `updatePRComment`). Includes the GitHub-supplied `updated_at` so downstream + * structured-mutation helpers can surface a real provider timestamp rather + * than a synthetic `new Date().toISOString()` (MNG-1425 / spec MNG-1422). + */ +export interface CreatedIssueComment { + id: number; + htmlUrl: string; + body: string; + createdAt: string; + updatedAt: string; +} + export interface PRReview { id: number; state: 'approved' | 'changes_requested' | 'commented' | 'dismissed'; @@ -116,6 +138,22 @@ export interface CreatedPR { title: string; } +/** + * Result shape for `createPRReview`. Surfaces the GitHub-supplied `state` + * (e.g. `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`) and `submitted_at` so + * downstream structured-mutation helpers can record the real provider + * timestamp (MNG-1425 / spec MNG-1422). `submittedAt` is nullable because + * `pulls.createReview` returns `null` for `PENDING` reviews even though + * gadget callers always submit (event != null). + */ +export interface CreatedPRReview { + id: number; + htmlUrl: string; + body: string; + state: string; + submittedAt: string | null; +} + export const githubClient = { async getPR(owner: string, repo: string, prNumber: number): Promise { logger.debug('Fetching PR', { owner, repo, prNumber }); @@ -191,6 +229,7 @@ export const githubClient = { login: data.user?.login || 'unknown', }, createdAt: data.created_at, + updatedAt: data.updated_at, inReplyToId: data.in_reply_to_id, }; }, @@ -200,7 +239,7 @@ export const githubClient = { repo: string, prNumber: number, body: string, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Creating PR comment', { owner, repo, prNumber }); const { data } = await getClient().issues.createComment({ owner, @@ -211,6 +250,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + createdAt: data.created_at, + updatedAt: data.updated_at, }; }, @@ -219,7 +261,7 @@ export const githubClient = { repo: string, commentId: number, body: string, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Updating PR comment', { owner, repo, commentId }); const { data } = await getClient().issues.updateComment({ owner, @@ -230,6 +272,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + createdAt: data.created_at, + updatedAt: data.updated_at, }; }, @@ -401,7 +446,7 @@ export const githubClient = { event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body: string, comments?: Array<{ path: string; line?: number; body: string }>, - ): Promise<{ id: number; htmlUrl: string }> { + ): Promise { logger.debug('Creating PR review', { owner, repo, prNumber, event }); const { data } = await getClient().pulls.createReview({ owner, @@ -418,6 +463,9 @@ export const githubClient = { return { id: data.id, htmlUrl: data.html_url, + body: data.body ?? '', + state: data.state, + submittedAt: data.submitted_at ?? null, }; }, diff --git a/tests/unit/cli/scm/scm-commands.test.ts b/tests/unit/cli/scm/scm-commands.test.ts index 807e59ea7..26bd0ce2f 100644 --- a/tests/unit/cli/scm/scm-commands.test.ts +++ b/tests/unit/cli/scm/scm-commands.test.ts @@ -445,3 +445,84 @@ describe('UpdatePRComment command', () => { expect(output.data).toEqual({ id: 555, body: 'New content' }); }); }); + +// --------------------------------------------------------------------------- +// MNG-1425: runtime failure envelopes +// +// The structured-output rewrite of post-pr-comment / update-pr-comment / +// reply-to-review-comment cores throws on GitHub failures rather than +// returning prose sentinel strings. The CLI factory (`createCLICommand`) +// wraps thrown errors in the spec-014 runtime envelope. These tests pin +// that contract per CLI so a regression here surfaces immediately. +// --------------------------------------------------------------------------- +describe('SCM CLI runtime failure envelopes (MNG-1425)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + error?: { type: string; message: string }; + }; + } + + /** + * Runtime failures emit the envelope, then call exit(1). Oclif's exit + * surfaces as a thrown EEXIT error from `cmd.run()`, which is the expected + * post-envelope shape — we swallow it so we can inspect the envelope. + */ + async function runExpectingExit(cmd: { run: () => Promise }): Promise { + try { + await cmd.run(); + } catch (err) { + const status = (err as { oclif?: { exit?: number }; code?: string })?.oclif?.exit; + const code = (err as { code?: string })?.code; + if (status === 1 || code === 'EEXIT') return; + throw err; + } + } + + it('PostPRComment surfaces a runtime envelope when postPRComment throws', async () => { + vi.mocked(postPRComment).mockRejectedValueOnce(new Error('Rate limited')); + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Hello'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Rate limited'); + }); + + it('UpdatePRComment surfaces a runtime envelope when updatePRComment throws', async () => { + vi.mocked(updatePRComment).mockRejectedValueOnce(new Error('Not Found')); + const cmd = new UpdatePRComment( + ['--commentId', '555', '--body', 'New content'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Not Found'); + }); + + it('ReplyToReviewComment surfaces a runtime envelope when replyToReviewComment throws', async () => { + vi.mocked(replyToReviewComment).mockRejectedValueOnce(new Error('Unprocessable Entity')); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '101', '--body', 'Reply'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Unprocessable Entity'); + }); +}); diff --git a/tests/unit/gadgets/github/core/misc.test.ts b/tests/unit/gadgets/github/core/misc.test.ts index 6ada43437..95f45f3fb 100644 --- a/tests/unit/gadgets/github/core/misc.test.ts +++ b/tests/unit/gadgets/github/core/misc.test.ts @@ -278,74 +278,96 @@ describe('getPRComments', () => { }); describe('postPRComment', () => { - it('returns success with comment ID and URL', async () => { + it('returns success with structured fields', async () => { mockGithub.createPRComment.mockResolvedValue({ id: 50, htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-50', + body: 'Nice work!', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('o', 'r', 1, 'Nice work!'); - expect(result).toContain('id: 50'); - expect(result).toContain('issuecomment-50'); + expect(result.id).toBe('50'); + expect(result.url).toBe('https://github.com/o/r/pull/1#issuecomment-50'); + expect(result.status).toBe('ok'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.createPRComment.mockRejectedValue(new Error('Rate limited')); - const result = await postPRComment('o', 'r', 1, 'Comment'); - - expect(result).toBe('Error posting PR comment: Rate limited'); + await expect(postPRComment('o', 'r', 1, 'Comment')).rejects.toThrow('Rate limited'); }); }); describe('updatePRComment', () => { - it('returns success with comment ID and URL', async () => { + it('returns success with structured fields', async () => { mockGithub.updatePRComment.mockResolvedValue({ id: 50, htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-50', + body: 'Updated content', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', } as Awaited>); const result = await updatePRComment('o', 'r', 50, 'Updated content'); - expect(result).toContain('id: 50'); - expect(result).toContain('Comment updated'); + expect(result.id).toBe('50'); + expect(result.status).toBe('ok'); + expect(result.url).toBe('https://github.com/o/r/pull/1#issuecomment-50'); + expect(result.updatedAt).toBe('2026-05-02T11:00:00Z'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.updatePRComment.mockRejectedValue(new Error('Not found')); - const result = await updatePRComment('o', 'r', 50, 'Content'); - - expect(result).toBe('Error updating PR comment: Not found'); + await expect(updatePRComment('o', 'r', 50, 'Content')).rejects.toThrow('Not found'); }); }); describe('replyToReviewComment', () => { - it('returns success with reply URL', async () => { + it('returns success with structured fields', async () => { mockGithub.replyToReviewComment.mockResolvedValue({ + id: 200, + body: 'Acknowledged', + path: 'src/index.ts', + line: 5, htmlUrl: 'https://github.com/o/r/pull/1#reply-200', + user: { login: 'bot' }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', + inReplyToId: 100, } as Awaited>); const result = await replyToReviewComment('o', 'r', 1, 100, 'Acknowledged'); - expect(result).toContain('Reply posted successfully'); - expect(result).toContain('reply-200'); + expect(result.id).toBe('200'); + expect(result.status).toBe('ok'); + expect(result.url).toBe('https://github.com/o/r/pull/1#reply-200'); + expect(result.prNumber).toBe(1); + expect(result.repoFullName).toBe('o/r'); }); - it('returns error on failure', async () => { + it('throws on failure', async () => { mockGithub.replyToReviewComment.mockRejectedValue(new Error('Not found')); - const result = await replyToReviewComment('o', 'r', 1, 100, 'Reply'); - - expect(result).toBe('Error replying to comment: Not found'); + await expect(replyToReviewComment('o', 'r', 1, 100, 'Reply')).rejects.toThrow('Not found'); }); }); describe('createPRReview', () => { - it('creates review and returns reviewUrl + event', async () => { + it('creates review and returns structured fields', async () => { mockGithub.createPRReview.mockResolvedValue({ + id: 300, htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', + body: 'LGTM', + state: 'APPROVED', + submittedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await createPRReview({ @@ -356,19 +378,34 @@ describe('createPRReview', () => { body: 'LGTM', }); - expect(result).toEqual({ + expect(result).toMatchObject({ + id: '300', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/o/r/pull/1#pullrequestreview-300', reviewUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', event: 'APPROVE', + repoFullName: 'o/r', + prNumber: 1, + submittedAt: '2026-05-01T10:00:00Z', + inlineCommentCount: 0, }); }); - it('passes inline comments when provided', async () => { + it('passes inline comments through and reports their count', async () => { mockGithub.createPRReview.mockResolvedValue({ - htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-300', + id: 301, + htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-301', + body: 'Needs work', + state: 'CHANGES_REQUESTED', + submittedAt: '2026-05-01T10:00:00Z', } as Awaited>); - const comments = [{ path: 'file.ts', line: 10, body: 'Fix' }]; - await createPRReview({ + const comments = [ + { path: 'file.ts', line: 10, body: 'Fix' }, + { path: 'other.ts', line: 5, body: 'Also fix' }, + ]; + const result = await createPRReview({ owner: 'o', repo: 'r', prNumber: 1, @@ -385,6 +422,8 @@ describe('createPRReview', () => { 'Needs work', comments, ); + expect(result.inlineCommentCount).toBe(2); + expect(result.event).toBe('REQUEST_CHANGES'); }); it('throws on failure (no try/catch)', async () => { @@ -400,4 +439,25 @@ describe('createPRReview', () => { }), ).rejects.toThrow('Forbidden'); }); + + it('synthesises a submittedAt timestamp when GitHub omits it', async () => { + mockGithub.createPRReview.mockResolvedValue({ + id: 302, + htmlUrl: 'https://github.com/o/r/pull/1#pullrequestreview-302', + body: 'pending', + state: 'PENDING', + submittedAt: null, + } as Awaited>); + + const result = await createPRReview({ + owner: 'o', + repo: 'r', + prNumber: 1, + event: 'COMMENT', + body: 'pending', + }); + + expect(result.submittedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(result.updatedAt).toBe(result.submittedAt); + }); }); diff --git a/tests/unit/gadgets/github/core/postPRComment.test.ts b/tests/unit/gadgets/github/core/postPRComment.test.ts index a5f38a242..96866eca3 100644 --- a/tests/unit/gadgets/github/core/postPRComment.test.ts +++ b/tests/unit/gadgets/github/core/postPRComment.test.ts @@ -22,18 +22,26 @@ describe('postPRComment', () => { vi.clearAllMocks(); }); - it('returns "Comment posted" with id and URL on success (no run link footer)', async () => { + it('returns a structured PostPRCommentResult on success (no run link footer)', async () => { mockBuildRunLinkFooter.mockReturnValue(null); mockGithub.createPRComment.mockResolvedValue({ id: 123, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-123', + body: 'Hello from test', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('owner', 'repo', 42, 'Hello from test'); - expect(result).toBe( - 'Comment posted (id: 123): https://github.com/owner/repo/pull/42#issuecomment-123', - ); + expect(result).toEqual({ + id: '123', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/owner/repo/pull/42#issuecomment-123', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.createPRComment).toHaveBeenCalledWith('owner', 'repo', 42, 'Hello from test'); }); @@ -42,6 +50,9 @@ describe('postPRComment', () => { mockGithub.createPRComment.mockResolvedValue({ id: 456, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-456', + body: 'My comment\n\n[Run details](https://example.com/run/1)', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', } as Awaited>); const result = await postPRComment('owner', 'repo', 42, 'My comment'); @@ -52,17 +63,34 @@ describe('postPRComment', () => { 42, 'My comment\n\n[Run details](https://example.com/run/1)', ); - expect(result).toBe( - 'Comment posted (id: 456): https://github.com/owner/repo/pull/42#issuecomment-456', - ); + expect(result).toMatchObject({ + id: '456', + status: 'ok', + url: 'https://github.com/owner/repo/pull/42#issuecomment-456', + repoFullName: 'owner/repo', + prNumber: 42, + }); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockBuildRunLinkFooter.mockReturnValue(null); mockGithub.createPRComment.mockRejectedValue(new Error('Forbidden')); - const result = await postPRComment('owner', 'repo', 42, 'My comment'); + await expect(postPRComment('owner', 'repo', 42, 'My comment')).rejects.toThrow('Forbidden'); + }); + + it('surfaces the GitHub-supplied updatedAt timestamp', async () => { + mockBuildRunLinkFooter.mockReturnValue(null); + mockGithub.createPRComment.mockResolvedValue({ + id: 789, + htmlUrl: 'https://github.com/o/r/pull/1#issuecomment-789', + body: 'Body', + createdAt: '2025-12-01T01:02:03Z', + updatedAt: '2025-12-01T01:02:03Z', + } as Awaited>); + + const result = await postPRComment('o', 'r', 1, 'Body'); - expect(result).toBe('Error posting PR comment: Forbidden'); + expect(result.updatedAt).toBe('2025-12-01T01:02:03Z'); }); }); diff --git a/tests/unit/gadgets/github/core/replyToReviewComment.test.ts b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts index d0962fc89..e6e11c24c 100644 --- a/tests/unit/gadgets/github/core/replyToReviewComment.test.ts +++ b/tests/unit/gadgets/github/core/replyToReviewComment.test.ts @@ -16,16 +16,29 @@ describe('replyToReviewComment', () => { vi.clearAllMocks(); }); - it('returns "Reply posted successfully" with URL on success', async () => { + it('returns a structured ReplyToReviewCommentResult on success', async () => { mockGithub.replyToReviewComment.mockResolvedValue({ + id: 999, + body: 'Looks good!', + path: 'src/index.ts', + line: 5, htmlUrl: 'https://github.com/owner/repo/pull/42#discussion_r999', + user: { login: 'bot' }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', + inReplyToId: 101, } as Awaited>); const result = await replyToReviewComment('owner', 'repo', 42, 101, 'Looks good!'); - expect(result).toBe( - 'Reply posted successfully: https://github.com/owner/repo/pull/42#discussion_r999', - ); + expect(result).toEqual({ + id: '999', + status: 'ok', + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/owner/repo/pull/42#discussion_r999', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.replyToReviewComment).toHaveBeenCalledWith( 'owner', 'repo', @@ -35,11 +48,29 @@ describe('replyToReviewComment', () => { ); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockGithub.replyToReviewComment.mockRejectedValue(new Error('Unprocessable Entity')); - const result = await replyToReviewComment('owner', 'repo', 42, 101, 'My reply'); + await expect(replyToReviewComment('owner', 'repo', 42, 101, 'My reply')).rejects.toThrow( + 'Unprocessable Entity', + ); + }); + + it('falls back to createdAt when updatedAt is missing', async () => { + mockGithub.replyToReviewComment.mockResolvedValue({ + id: 1010, + body: 'My reply', + path: 'src/index.ts', + line: 1, + htmlUrl: 'https://github.com/owner/repo/pull/42#discussion_r1010', + user: { login: 'bot' }, + createdAt: '2026-04-01T10:00:00Z', + // updatedAt deliberately omitted to simulate the rare Octokit response shape + inReplyToId: 200, + } as Awaited>); + + const result = await replyToReviewComment('owner', 'repo', 42, 200, 'My reply'); - expect(result).toBe('Error replying to comment: Unprocessable Entity'); + expect(result.updatedAt).toBe('2026-04-01T10:00:00Z'); }); }); diff --git a/tests/unit/gadgets/github/core/updatePRComment.test.ts b/tests/unit/gadgets/github/core/updatePRComment.test.ts index 5daac47e4..b861047c9 100644 --- a/tests/unit/gadgets/github/core/updatePRComment.test.ts +++ b/tests/unit/gadgets/github/core/updatePRComment.test.ts @@ -16,25 +16,61 @@ describe('updatePRComment', () => { vi.clearAllMocks(); }); - it('returns "Comment updated" with id and URL on success', async () => { + it('returns a structured UpdatePRCommentResult on success', async () => { mockGithub.updatePRComment.mockResolvedValue({ id: 789, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-789', + body: 'Updated body', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', } as Awaited>); const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); - expect(result).toBe( - 'Comment updated (id: 789): https://github.com/owner/repo/pull/42#issuecomment-789', - ); + expect(result).toEqual({ + id: '789', + status: 'ok', + updatedAt: '2026-05-02T11:00:00Z', + url: 'https://github.com/owner/repo/pull/42#issuecomment-789', + repoFullName: 'owner/repo', + prNumber: 42, + }); expect(mockGithub.updatePRComment).toHaveBeenCalledWith('owner', 'repo', 789, 'Updated body'); }); - it('returns error message string when githubClient throws', async () => { + it('throws when githubClient throws (no prose sentinel)', async () => { mockGithub.updatePRComment.mockRejectedValue(new Error('Not Found')); - const result = await updatePRComment('owner', 'repo', 789, 'Updated body'); + await expect(updatePRComment('owner', 'repo', 789, 'Updated body')).rejects.toThrow( + 'Not Found', + ); + }); + + it('extracts prNumber from the comment html_url', async () => { + mockGithub.updatePRComment.mockResolvedValue({ + id: 555, + htmlUrl: 'https://github.com/big-co/platform/pull/9999#issuecomment-555', + body: 'New content', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + } as Awaited>); + + const result = await updatePRComment('big-co', 'platform', 555, 'New content'); + + expect(result.prNumber).toBe(9999); + }); + + it('returns prNumber=null when html_url does not match the /pull/ pattern', async () => { + mockGithub.updatePRComment.mockResolvedValue({ + id: 777, + htmlUrl: 'https://github.com/owner/repo/issues/42#issuecomment-777', + body: 'Body', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + } as Awaited>); + + const result = await updatePRComment('owner', 'repo', 777, 'Body'); - expect(result).toBe('Error updating PR comment: Not Found'); + expect(result.prNumber).toBeNull(); }); }); diff --git a/tests/unit/gadgets/github/createPRReview.test.ts b/tests/unit/gadgets/github/createPRReview.test.ts index 6c79d9849..24f872ff9 100644 --- a/tests/unit/gadgets/github/createPRReview.test.ts +++ b/tests/unit/gadgets/github/createPRReview.test.ts @@ -33,6 +33,22 @@ const BASE_PARAMS = { body: 'LGTM!', }; +function structuredReviewResult(overrides: Partial<{ reviewUrl: string; event: string }> = {}) { + return { + id: '1', + status: 'ok' as const, + updatedAt: '2026-05-01T10:00:00Z', + url: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + event: 'APPROVE' as const, + repoFullName: 'acme/myapp', + prNumber: 42, + submittedAt: '2026-05-01T10:00:00Z', + inlineCommentCount: 0, + ...overrides, + }; +} + describe('CreatePRReview', () => { let gadget: InstanceType; @@ -41,10 +57,7 @@ describe('CreatePRReview', () => { }); it('submits review, records it, and deletes ack comment on success', async () => { - mockCreatePRReview.mockResolvedValue({ - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', - event: 'APPROVE', - }); + mockCreatePRReview.mockResolvedValue(structuredReviewResult()); const result = await gadget.execute(BASE_PARAMS); @@ -66,10 +79,7 @@ describe('CreatePRReview', () => { }); it('does not fail if deleteInitialComment throws', async () => { - mockCreatePRReview.mockResolvedValue({ - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', - event: 'APPROVE', - }); + mockCreatePRReview.mockResolvedValue(structuredReviewResult()); // deleteInitialComment itself handles errors internally, but simulate it throwing mockDeleteInitialComment.mockRejectedValueOnce(new Error('GitHub API error')); diff --git a/tests/unit/github/client.test.ts b/tests/unit/github/client.test.ts index 48b3c82c5..8f4d47727 100644 --- a/tests/unit/github/client.test.ts +++ b/tests/unit/github/client.test.ts @@ -233,7 +233,7 @@ describe('githubClient', () => { }); describe('replyToReviewComment', () => { - it('creates reply and returns mapped result', async () => { + it('creates reply and returns mapped result including updatedAt', async () => { mockPulls.createReplyForReviewComment.mockResolvedValue({ data: { id: 99, @@ -243,6 +243,7 @@ describe('githubClient', () => { html_url: 'https://github.com/...', user: { login: 'bot' }, created_at: '2024-01-01', + updated_at: '2024-01-01T00:01:00Z', in_reply_to_id: 1, }, }); @@ -253,6 +254,7 @@ describe('githubClient', () => { expect(result.id).toBe(99); expect(result.inReplyToId).toBe(1); + expect(result.updatedAt).toBe('2024-01-01T00:01:00Z'); expect(mockPulls.createReplyForReviewComment).toHaveBeenCalledWith({ owner: 'owner', repo: 'repo', @@ -264,11 +266,14 @@ describe('githubClient', () => { }); describe('createPRComment', () => { - it('creates issue comment and returns id and url', async () => { + it('creates issue comment and returns id, url, body and timestamps', async () => { mockIssues.createComment.mockResolvedValue({ data: { id: 200, html_url: 'https://github.com/owner/repo/pull/42#issuecomment-200', + body: 'Hello', + created_at: '2026-05-01T10:00:00Z', + updated_at: '2026-05-01T10:00:00Z', }, }); @@ -279,6 +284,9 @@ describe('githubClient', () => { expect(result).toEqual({ id: 200, htmlUrl: 'https://github.com/owner/repo/pull/42#issuecomment-200', + body: 'Hello', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-01T10:00:00Z', }); expect(mockIssues.createComment).toHaveBeenCalledWith({ owner: 'owner', @@ -290,11 +298,14 @@ describe('githubClient', () => { }); describe('updatePRComment', () => { - it('updates comment and returns result', async () => { + it('updates comment and returns id, url, body and timestamps', async () => { mockIssues.updateComment.mockResolvedValue({ data: { id: 200, html_url: 'https://github.com/...', + body: 'Updated', + created_at: '2026-05-01T10:00:00Z', + updated_at: '2026-05-02T11:00:00Z', }, }); @@ -302,7 +313,13 @@ describe('githubClient', () => { githubClient.updatePRComment('owner', 'repo', 200, 'Updated'), ); - expect(result.id).toBe(200); + expect(result).toEqual({ + id: 200, + htmlUrl: 'https://github.com/...', + body: 'Updated', + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-02T11:00:00Z', + }); expect(mockIssues.updateComment).toHaveBeenCalledWith({ owner: 'owner', repo: 'repo', @@ -812,11 +829,14 @@ describe('githubClient', () => { }); describe('createPRReview', () => { - it('creates review and returns result', async () => { + it('creates review and returns id, url, body, state and submittedAt', async () => { mockPulls.createReview.mockResolvedValue({ data: { id: 500, html_url: 'https://github.com/...', + body: 'LGTM', + state: 'APPROVED', + submitted_at: '2026-05-01T10:00:00Z', }, }); @@ -827,6 +847,9 @@ describe('githubClient', () => { expect(result).toEqual({ id: 500, htmlUrl: 'https://github.com/...', + body: 'LGTM', + state: 'APPROVED', + submittedAt: '2026-05-01T10:00:00Z', }); expect(mockPulls.createReview).toHaveBeenCalledWith({ owner: 'owner', @@ -840,7 +863,13 @@ describe('githubClient', () => { it('passes file comments when provided', async () => { mockPulls.createReview.mockResolvedValue({ - data: { id: 501, html_url: 'url' }, + data: { + id: 501, + html_url: 'url', + body: 'Please fix', + state: 'CHANGES_REQUESTED', + submitted_at: '2026-05-01T10:00:00Z', + }, }); await withGitHubToken('test-token', () => @@ -855,6 +884,25 @@ describe('githubClient', () => { }), ); }); + + it('returns submittedAt=null when GitHub responds with a null submitted_at', async () => { + mockPulls.createReview.mockResolvedValue({ + data: { + id: 502, + html_url: 'url', + body: 'pending', + state: 'PENDING', + submitted_at: null, + }, + }); + + const result = await withGitHubToken('test-token', () => + githubClient.createPRReview('owner', 'repo', 42, 'COMMENT', 'pending'), + ); + + expect(result.submittedAt).toBeNull(); + expect(result.body).toBe('pending'); + }); }); describe('createPR', () => { From fb3cd3632881143974f7a5584859ac00cd56e168 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 1 Jun 2026 15:35:27 +0200 Subject: [PATCH 03/25] feat(pm): structured outputs for checklist mutations (MNG-1424) (#1388) Co-authored-by: Cascade Bot --- src/gadgets/pm/AddChecklist.ts | 16 +- src/gadgets/pm/DeleteChecklistItem.ts | 11 +- src/gadgets/pm/UpdateChecklistItem.ts | 17 +- src/gadgets/pm/core/addChecklist.ts | 60 ++- src/gadgets/pm/core/deleteChecklistItem.ts | 39 +- src/gadgets/pm/core/mutationResults.ts | 68 +++ src/gadgets/pm/core/readWorkItemContext.ts | 39 ++ src/gadgets/pm/core/updateChecklistItem.ts | 41 +- .../unit/gadgets/pm/core/addChecklist.test.ts | 405 ++++++++++++------ .../pm/core/deleteChecklistItem.test.ts | 65 ++- .../pm/core/readWorkItemContext.test.ts | 75 ++++ .../pm/core/updateChecklistItem.test.ts | 77 +++- 12 files changed, 714 insertions(+), 199 deletions(-) create mode 100644 src/gadgets/pm/core/readWorkItemContext.ts create mode 100644 tests/unit/gadgets/pm/core/readWorkItemContext.test.ts diff --git a/src/gadgets/pm/AddChecklist.ts b/src/gadgets/pm/AddChecklist.ts index 7e19fb6b5..623c3f9aa 100644 --- a/src/gadgets/pm/AddChecklist.ts +++ b/src/gadgets/pm/AddChecklist.ts @@ -1,11 +1,17 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { addChecklist, type ChecklistItemInput } from './core/addChecklist.js'; import { addChecklistDef } from './definitions.js'; export const AddChecklist = createGadgetClass(addChecklistDef, async (params) => { - return addChecklist({ - workItemId: params.workItemId as string, - checklistName: params.checklistName as string, - items: params.item as ChecklistItemInput[], - }); + try { + const result = await addChecklist({ + workItemId: params.workItemId as string, + checklistName: params.checklistName as string, + items: params.item as ChecklistItemInput[], + }); + return `Checklist "${result.checklistName}" created (id: ${result.checklistId}) with ${result.itemCount} items on ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('adding checklist', error); + } }); diff --git a/src/gadgets/pm/DeleteChecklistItem.ts b/src/gadgets/pm/DeleteChecklistItem.ts index c1f669569..587334a89 100644 --- a/src/gadgets/pm/DeleteChecklistItem.ts +++ b/src/gadgets/pm/DeleteChecklistItem.ts @@ -1,7 +1,16 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { deleteChecklistItem } from './core/deleteChecklistItem.js'; import { pmDeleteChecklistItemDef } from './definitions.js'; export const PMDeleteChecklistItem = createGadgetClass(pmDeleteChecklistItemDef, async (params) => { - return deleteChecklistItem(params.workItemId as string, params.checkItemId as string); + try { + const result = await deleteChecklistItem( + params.workItemId as string, + params.checkItemId as string, + ); + return `Checklist item ${result.checkItemId} deleted from ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('deleting checklist item', error); + } }); diff --git a/src/gadgets/pm/UpdateChecklistItem.ts b/src/gadgets/pm/UpdateChecklistItem.ts index 098809579..5c7e58817 100644 --- a/src/gadgets/pm/UpdateChecklistItem.ts +++ b/src/gadgets/pm/UpdateChecklistItem.ts @@ -1,11 +1,18 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updateChecklistItem } from './core/updateChecklistItem.js'; import { pmUpdateChecklistItemDef } from './definitions.js'; export const PMUpdateChecklistItem = createGadgetClass(pmUpdateChecklistItemDef, async (params) => { - return updateChecklistItem( - params.workItemId as string, - params.checkItemId as string, - (params.state as string) === 'complete', - ); + try { + const result = await updateChecklistItem( + params.workItemId as string, + params.checkItemId as string, + (params.state as string) === 'complete', + ); + const action = result.complete ? 'marked complete' : 'marked incomplete'; + return `Checklist item ${result.checkItemId} ${action} on ${result.workItemUrl}`; + } catch (error) { + return formatGadgetError('updating checklist item', error); + } }); diff --git a/src/gadgets/pm/core/addChecklist.ts b/src/gadgets/pm/core/addChecklist.ts index 8ed7af098..1381ce99d 100644 --- a/src/gadgets/pm/core/addChecklist.ts +++ b/src/gadgets/pm/core/addChecklist.ts @@ -1,5 +1,7 @@ import { getPMProvider } from '../../../pm/index.js'; -import type { ChecklistItemDraft } from '../../../pm/types.js'; +import type { Checklist, ChecklistItemDraft } from '../../../pm/types.js'; +import type { ChecklistCreatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; export type ChecklistItemInput = string | { name: string; description?: string }; @@ -9,7 +11,26 @@ export interface AddChecklistParams { items: ChecklistItemInput[]; } -export async function addChecklist(params: AddChecklistParams): Promise { +/** + * Create a checklist on a work item with one or more items. + * + * Returns a structured `ChecklistCreatedResult` so downstream consumers can + * branch on shape rather than parsing prose. The result carries the freshly- + * created checklist identity, parent work-item URL/timestamp (read back from + * the provider), the item count, and any per-item IDs the provider surfaced. + * + * Inline-description providers (Linear, JIRA) consume the optional bulk + * `createChecklistWithItems` fast path and emit deterministic hashed IDs in + * `result.itemIds`. Trello's native-checklist provider falls through to the + * per-item path; `addChecklistItem` returns `void` there so `itemIds` ends up + * empty — that's expected, and the field documentation calls it out. + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at `src/gadgets/pm/AddChecklist.ts` + * wraps thrown errors with `formatGadgetError`; the CLI factory wraps them in + * the spec-014 runtime envelope. + */ +export async function addChecklist(params: AddChecklistParams): Promise { if (params.items.length === 0) { throw new Error('At least one checklist item is required'); } @@ -17,25 +38,36 @@ export async function addChecklist(params: AddChecklistParams): Promise const provider = getPMProvider(); const items = params.items.map(normalizeChecklistItem); + let checklist: Checklist; if (typeof provider.createChecklistWithItems === 'function') { - await provider.createChecklistWithItems(params.workItemId, params.checklistName, items); - return successMessage(params.workItemId, params.checklistName, items.length); + checklist = await provider.createChecklistWithItems( + params.workItemId, + params.checklistName, + items, + ); + } else { + checklist = await provider.createChecklist(params.workItemId, params.checklistName); + for (const item of items) { + await provider.addChecklistItem(checklist.id, item.name, item.checked, item.description); + } } - const checklist = await provider.createChecklist(params.workItemId, params.checklistName); + const itemIds = (checklist.items ?? []).map((i) => i.id); + const { workItemUrl, updatedAt } = await readWorkItemContext(params.workItemId); - for (const item of items) { - await provider.addChecklistItem(checklist.id, item.name, item.checked, item.description); - } - - return successMessage(params.workItemId, params.checklistName, items.length); + return { + status: 'created', + checklistId: checklist.id, + checklistName: params.checklistName, + workItemId: params.workItemId, + workItemUrl, + updatedAt, + itemCount: items.length, + itemIds, + }; } function normalizeChecklistItem(item: ChecklistItemInput): ChecklistItemDraft { if (typeof item === 'string') return { name: item, checked: false }; return { name: item.name, checked: false, description: item.description }; } - -function successMessage(workItemId: string, checklistName: string, itemCount: number): string { - return `Checklist "${checklistName}" created with ${itemCount} items on work item ${workItemId}`; -} diff --git a/src/gadgets/pm/core/deleteChecklistItem.ts b/src/gadgets/pm/core/deleteChecklistItem.ts index a5c3e6fa0..09588a6c3 100644 --- a/src/gadgets/pm/core/deleteChecklistItem.ts +++ b/src/gadgets/pm/core/deleteChecklistItem.ts @@ -1,14 +1,37 @@ import { getPMProvider } from '../../../pm/index.js'; +import type { ChecklistItemDeletedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; +/** + * Delete a checklist item from a work item. + * + * Returns a structured `ChecklistItemDeletedResult` so downstream consumers + * can branch on shape rather than parsing prose. The result carries the parent + * work-item context (read back from the provider for URL + timestamp), the + * deleted `checkItemId`, and the action status (`'deleted'`). + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at + * `src/gadgets/pm/DeleteChecklistItem.ts` wraps thrown errors with + * `formatGadgetError`; the CLI factory wraps them in the spec-014 runtime + * envelope. Read-back failures after a successful mutation fall back to a + * synthesised URL + timestamp inside `readWorkItemContext` rather than + * masking the mutation success. + */ export async function deleteChecklistItem( workItemId: string, checkItemId: string, -): Promise { - try { - await getPMProvider().deleteChecklistItem(workItemId, checkItemId); - return `Checklist item ${checkItemId} deleted from work item ${workItemId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error deleting checklist item: ${message}`); - } +): Promise { + const provider = getPMProvider(); + await provider.deleteChecklistItem(workItemId, checkItemId); + + const { workItemUrl, updatedAt } = await readWorkItemContext(workItemId); + + return { + status: 'deleted', + workItemId, + workItemUrl, + checkItemId, + updatedAt, + }; } diff --git a/src/gadgets/pm/core/mutationResults.ts b/src/gadgets/pm/core/mutationResults.ts index 5ed907b49..b2fb69819 100644 --- a/src/gadgets/pm/core/mutationResults.ts +++ b/src/gadgets/pm/core/mutationResults.ts @@ -151,3 +151,71 @@ export function abortedResult(args: { if (args.message) result.message = args.message; return result; } + +// ─── Checklist mutation result contracts (MNG-1424) ───────────────────────── +// +// PM checklist mutations have action-specific outcome statuses (`created`, +// `updated`, `deleted`) rather than the generic `'ok' | 'no-op' | 'aborted'` +// outcomes used for work-item/comment mutations. They live in this shared +// module so consumers can import all PM mutation result shapes from a single +// surface; `pickTimestamp` / `currentTimestamp` above are reused as-is. +// +// Timestamp policy mirrors the parent contract: provider-supplied timestamps +// win when available; we fall back to `currentTimestamp()` only when the +// provider's read-back omits an `updatedAt` (e.g. legacy code paths). The +// mutation itself already succeeded, so the structured result never throws +// just because the timestamp can't be sourced from the provider. + +/** + * Result returned by `addChecklist`. Carries the freshly-created checklist's + * identity (`checklistId`, `checklistName`), the parent work-item context + * (`workItemId`, `workItemUrl`), the action status (`'created'`), + * provider-preferred `updatedAt`, the number of items written, and the per-item + * IDs the provider surfaced. + * + * `itemIds` is best-effort — the inline-description providers (Linear, JIRA) + * return deterministic hashed IDs from `createChecklistWithItems`, while + * Trello's native-checklist per-item fallback path does not surface IDs from + * `addChecklistItem`. The field is always present (as an array) so consumers + * never branch on `undefined`; it may be empty when the provider did not + * return IDs. + */ +export interface ChecklistCreatedResult { + status: 'created'; + checklistId: string; + checklistName: string; + workItemId: string; + workItemUrl: string; + updatedAt: string; + itemCount: number; + itemIds: string[]; +} + +/** + * Result returned by `updateChecklistItem`. Surfaces the work-item context + * (`workItemId`, `workItemUrl`), the affected item ID (`checkItemId`), the + * resulting boolean state (`complete`), the action status (`'updated'`), + * and a provider-preferred `updatedAt`. Used by consumers that want to + * confirm both the request was acknowledged AND the resulting state. + */ +export interface ChecklistItemUpdatedResult { + status: 'updated'; + workItemId: string; + workItemUrl: string; + checkItemId: string; + complete: boolean; + updatedAt: string; +} + +/** + * Result returned by `deleteChecklistItem`. Surfaces the work-item context + * (`workItemId`, `workItemUrl`), the deleted item ID (`checkItemId`), the + * action status (`'deleted'`), and a provider-preferred `updatedAt`. + */ +export interface ChecklistItemDeletedResult { + status: 'deleted'; + workItemId: string; + workItemUrl: string; + checkItemId: string; + updatedAt: string; +} diff --git a/src/gadgets/pm/core/readWorkItemContext.ts b/src/gadgets/pm/core/readWorkItemContext.ts new file mode 100644 index 000000000..ef86e617d --- /dev/null +++ b/src/gadgets/pm/core/readWorkItemContext.ts @@ -0,0 +1,39 @@ +import { getPMProvider } from '../../../pm/index.js'; +import { pickTimestamp } from './mutationResults.js'; + +/** + * Shared read-back helper used by PM checklist mutation cores + * (`addChecklist`, `updateChecklistItem`, `deleteChecklistItem`) to surface + * the parent work-item's URL + `updatedAt` on the structured result. + * + * Implements the technical-notes pattern from MNG-1424: "Use work-item + * read-back for URL/status/timestamp where provider APIs do not return deep + * checklist links." The Trello, JIRA, and Linear adapters all surface + * `updatedAt` on `WorkItem` when the provider reports it. + * + * Read-back failure handling: the calling mutation has ALREADY succeeded by + * the time this helper runs. Propagating a read-back exception would mask the + * mutation success and risk an idempotency retry storm — especially on the + * native-checklist provider (Trello) where a retried `addChecklistItem` + * duplicates rows. We therefore swallow the read-back error and fall back to + * the synchronous `getWorkItemUrl(id)` constructor plus a synthesised current + * ISO timestamp. The mutation success is preserved; the timestamp is just + * synthesised rather than provider-supplied. + */ +export async function readWorkItemContext( + workItemId: string, +): Promise<{ workItemUrl: string; updatedAt: string }> { + const provider = getPMProvider(); + try { + const item = await provider.getWorkItem(workItemId); + return { + workItemUrl: item.url, + updatedAt: pickTimestamp(item.updatedAt), + }; + } catch { + return { + workItemUrl: provider.getWorkItemUrl(workItemId), + updatedAt: pickTimestamp(undefined), + }; + } +} diff --git a/src/gadgets/pm/core/updateChecklistItem.ts b/src/gadgets/pm/core/updateChecklistItem.ts index 2f110924d..45c4344d2 100644 --- a/src/gadgets/pm/core/updateChecklistItem.ts +++ b/src/gadgets/pm/core/updateChecklistItem.ts @@ -1,17 +1,40 @@ import { getPMProvider } from '../../../pm/index.js'; +import type { ChecklistItemUpdatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; +/** + * Toggle a checklist item's complete state on a work item. + * + * Returns a structured `ChecklistItemUpdatedResult` so downstream consumers + * can branch on shape rather than parsing prose. The result carries the parent + * work-item context (read back from the provider for URL + timestamp), the + * affected `checkItemId`, the resulting boolean state, and the action status + * (`'updated'`). + * + * Runtime provider errors propagate (no internal try/catch) per the spec + * MNG-1424 contract. The gadget wrapper at + * `src/gadgets/pm/UpdateChecklistItem.ts` wraps thrown errors with + * `formatGadgetError`; the CLI factory wraps them in the spec-014 runtime + * envelope. Read-back failures after a successful mutation fall back to a + * synthesised URL + timestamp inside `readWorkItemContext` rather than + * masking the mutation success. + */ export async function updateChecklistItem( workItemId: string, checkItemId: string, complete: boolean, -): Promise { - try { - await getPMProvider().updateChecklistItem(workItemId, checkItemId, complete); +): Promise { + const provider = getPMProvider(); + await provider.updateChecklistItem(workItemId, checkItemId, complete); - const action = complete ? 'marked complete' : 'marked incomplete'; - return `Checklist item ${checkItemId} ${action} on work item ${workItemId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error updating checklist item: ${message}`); - } + const { workItemUrl, updatedAt } = await readWorkItemContext(workItemId); + + return { + status: 'updated', + workItemId, + workItemUrl, + checkItemId, + complete, + updatedAt, + }; } diff --git a/tests/unit/gadgets/pm/core/addChecklist.test.ts b/tests/unit/gadgets/pm/core/addChecklist.test.ts index 8063b528f..8bd002d58 100644 --- a/tests/unit/gadgets/pm/core/addChecklist.test.ts +++ b/tests/unit/gadgets/pm/core/addChecklist.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -14,172 +14,311 @@ const providerWithBulk = mockProvider as typeof mockProvider & { createChecklistWithItems?: ReturnType; }; +beforeEach(() => { + vi.clearAllMocks(); + delete providerWithBulk.createChecklistWithItems; + // Default work-item read-back for URL + updatedAt. Individual tests + // override `getWorkItem` to stub provider-specific timestamps. + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('addChecklist', () => { - beforeEach(() => { - vi.clearAllMocks(); - delete providerWithBulk.createChecklistWithItems; - }); + describe('inline-style (bulk path / createChecklistWithItems)', () => { + it('uses provider bulk creation when available and returns the structured result', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + // Inline-description providers (Linear/JIRA) return deterministic + // hashed item IDs from the bulk path — mirror that shape here. + items: [ + { id: 'task-a-hash', name: 'Task A', complete: false }, + { id: 'task-b-hash', name: 'Task B', complete: false }, + ], + }); - it('uses provider bulk creation when available', async () => { - providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ - id: 'cl1', - name: 'My Tasks', - workItemId: 'item1', - items: [], - }); + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A', { name: 'Task B', description: 'Details' }], + }); - const result = await addChecklist({ - workItemId: 'item1', - checklistName: 'My Tasks', - items: ['Task A', { name: 'Task B', description: 'Details' }], + expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledTimes(1); + expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledWith('item1', 'My Tasks', [ + { name: 'Task A', checked: false }, + { name: 'Task B', checked: false, description: 'Details' }, + ]); + expect(mockProvider.createChecklist).not.toHaveBeenCalled(); + expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'created', + checklistId: 'cl1', + checklistName: 'My Tasks', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + itemCount: 2, + itemIds: ['task-a-hash', 'task-b-hash'], + }); }); - expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledTimes(1); - expect(providerWithBulk.createChecklistWithItems).toHaveBeenCalledWith('item1', 'My Tasks', [ - { name: 'Task A', checked: false }, - { name: 'Task B', checked: false, description: 'Details' }, - ]); - expect(mockProvider.createChecklist).not.toHaveBeenCalled(); - expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); - expect(result).toBe('Checklist "My Tasks" created with 2 items on work item item1'); - }); + it('returns an empty itemIds array when the bulk path returns no item IDs', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + items: [], + }); - it('creates checklist and adds string items', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'My Tasks', - workItemId: 'item1', - items: [], - }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A'], + }); - const result = await addChecklist({ - workItemId: 'item1', - checklistName: 'My Tasks', - items: ['Task A', 'Task B'], + expect(result.itemIds).toEqual([]); + expect(result.itemCount).toBe(1); }); - - expect(mockProvider.createChecklist).toHaveBeenCalledWith('item1', 'My Tasks'); - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A', false, undefined); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B', false, undefined); - expect(result).toBe('Checklist "My Tasks" created with 2 items on work item item1'); }); - it('creates checklist and adds object items with descriptions', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Steps', - workItemId: 'PROJ-42', - items: [], - }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); - - const result = await addChecklist({ - workItemId: 'PROJ-42', - checklistName: 'Steps', - items: [ - { name: 'Add endpoint', description: '**Files:** `src/api.ts`\n- Add POST route' }, - { name: 'Write tests' }, - ], + describe('native-style (per-item fallback path)', () => { + it('creates checklist and adds string items', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'My Tasks', + workItemId: 'item1', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'My Tasks', + items: ['Task A', 'Task B'], + }); + + expect(mockProvider.createChecklist).toHaveBeenCalledWith('item1', 'My Tasks'); + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task A', false, undefined); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith('cl1', 'Task B', false, undefined); + expect(result).toEqual({ + status: 'created', + checklistId: 'cl1', + checklistName: 'My Tasks', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + itemCount: 2, + itemIds: [], + }); }); - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Add endpoint', - false, - '**Files:** `src/api.ts`\n- Add POST route', - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Write tests', - false, - undefined, - ); - expect(result).toBe('Checklist "Steps" created with 2 items on work item PROJ-42'); - }); + it('creates checklist and adds object items with descriptions', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Steps', + workItemId: 'PROJ-42', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2026-03-15T13:00:00.000Z', + }), + ); + + const result = await addChecklist({ + workItemId: 'PROJ-42', + checklistName: 'Steps', + items: [ + { name: 'Add endpoint', description: '**Files:** `src/api.ts`\n- Add POST route' }, + { name: 'Write tests' }, + ], + }); - it('handles mixed string and object items', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Mixed', - workItemId: 'item1', - items: [], + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(2); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Add endpoint', + false, + '**Files:** `src/api.ts`\n- Add POST route', + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Write tests', + false, + undefined, + ); + expect(result).toMatchObject({ + status: 'created', + checklistId: 'cl1', + checklistName: 'Steps', + workItemId: 'PROJ-42', + workItemUrl: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2026-03-15T13:00:00.000Z', + itemCount: 2, + }); }); - mockProvider.addChecklistItem.mockResolvedValue(undefined); - await addChecklist({ - workItemId: 'item1', - checklistName: 'Mixed', - items: [ + it('handles mixed string and object items', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Mixed', + workItemId: 'item1', + items: [], + }); + mockProvider.addChecklistItem.mockResolvedValue(undefined); + + const result = await addChecklist({ + workItemId: 'item1', + checklistName: 'Mixed', + items: [ + 'Simple string item', + { name: 'Object item', description: 'Detailed description' }, + 'Another string', + ], + }); + + expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', 'Simple string item', - { name: 'Object item', description: 'Detailed description' }, + false, + undefined, + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', + 'Object item', + false, + 'Detailed description', + ); + expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( + 'cl1', 'Another string', - ], + false, + undefined, + ); + expect(result.itemCount).toBe(3); }); - - expect(mockProvider.addChecklistItem).toHaveBeenCalledTimes(3); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Simple string item', - false, - undefined, - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Object item', - false, - 'Detailed description', - ); - expect(mockProvider.addChecklistItem).toHaveBeenCalledWith( - 'cl1', - 'Another string', - false, - undefined, - ); }); - it('throws error when creating checklist with no items', async () => { - await expect( - addChecklist({ + describe('validation and provider errors', () => { + it('throws error when creating checklist with no items', async () => { + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Empty', + items: [], + }), + ).rejects.toThrow('At least one checklist item is required'); + + expect(mockProvider.createChecklist).not.toHaveBeenCalled(); + expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + }); + + it('throws on createChecklist failure (no prose sentinel)', async () => { + mockProvider.createChecklist.mockRejectedValue(new Error('API error')); + + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('API error'); + }); + + it('throws on createChecklistWithItems failure (no prose sentinel)', async () => { + providerWithBulk.createChecklistWithItems = vi + .fn() + .mockRejectedValue(new Error('Bulk creation failed')); + + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('Bulk creation failed'); + }); + + it('throws if addChecklistItem fails (no prose sentinel)', async () => { + mockProvider.createChecklist.mockResolvedValue({ + id: 'cl1', + name: 'Tasks', workItemId: 'item1', - checklistName: 'Empty', items: [], - }), - ).rejects.toThrow('At least one checklist item is required'); + }); + mockProvider.addChecklistItem.mockRejectedValue(new Error('Add item failed')); - expect(mockProvider.createChecklist).not.toHaveBeenCalled(); - expect(mockProvider.addChecklistItem).not.toHaveBeenCalled(); + await expect( + addChecklist({ + workItemId: 'item1', + checklistName: 'Tasks', + items: ['A'], + }), + ).rejects.toThrow('Add item failed'); + }); }); - it('throws on createChecklist failure', async () => { - mockProvider.createChecklist.mockRejectedValue(new Error('API error')); + describe('read-back fallback', () => { + // A successful mutation must not be masked by a failing work-item + // read-back — the helper at `readWorkItemContext` swallows the + // read-back error and synthesises a fallback URL+timestamp. + it('falls back to getWorkItemUrl + synthesised timestamp when read-back throws', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'Tasks', + workItemId: 'item1', + items: [{ id: 'a-hash', name: 'A', complete: false }], + }); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); - await expect( - addChecklist({ + const result = await addChecklist({ workItemId: 'item1', checklistName: 'Tasks', items: ['A'], - }), - ).rejects.toThrow('API error'); - }); + }); - it('throws if addChecklistItem fails', async () => { - mockProvider.createChecklist.mockResolvedValue({ - id: 'cl1', - name: 'Tasks', - workItemId: 'item1', - items: [], + expect(result.status).toBe('created'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); }); - mockProvider.addChecklistItem.mockRejectedValue(new Error('Add item failed')); - await expect( - addChecklist({ + it('synthesises updatedAt when the provider omits it on read-back', async () => { + providerWithBulk.createChecklistWithItems = vi.fn().mockResolvedValue({ + id: 'cl1', + name: 'Tasks', + workItemId: 'item1', + items: [], + }); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: undefined, + }), + ); + + const result = await addChecklist({ workItemId: 'item1', checklistName: 'Tasks', items: ['A'], - }), - ).rejects.toThrow('Add item failed'); + }); + + expect(result.workItemUrl).toBe('https://trello.com/c/item1'); + expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); }); }); diff --git a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts index 5b93d8d4a..e53e58ff6 100644 --- a/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/deleteChecklistItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,29 +10,72 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { deleteChecklistItem } from '../../../../../src/gadgets/pm/core/deleteChecklistItem.js'; +beforeEach(() => { + vi.clearAllMocks(); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('deleteChecklistItem', () => { - it('deletes a checklist item and returns success message', async () => { + it('deletes a checklist item and returns the structured result', async () => { mockProvider.deleteChecklistItem.mockResolvedValue(undefined); const result = await deleteChecklistItem('item1', 'checkItem1'); expect(mockProvider.deleteChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1'); - expect(result).toBe('Checklist item checkItem1 deleted from work item item1'); + expect(result).toEqual({ + status: 'deleted', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('throws an error message on failure', async () => { + it('throws when the provider mutation fails (no prose sentinel)', async () => { mockProvider.deleteChecklistItem.mockRejectedValue(new Error('API error')); - await expect(deleteChecklistItem('item1', 'checkItem1')).rejects.toThrow( - 'Error deleting checklist item: API error', - ); + await expect(deleteChecklistItem('item1', 'checkItem1')).rejects.toThrow('API error'); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values as-is', async () => { mockProvider.deleteChecklistItem.mockRejectedValue('string error'); - await expect(deleteChecklistItem('item1', 'ci1')).rejects.toThrow( - 'Error deleting checklist item: string error', + await expect(deleteChecklistItem('item1', 'ci1')).rejects.toBe('string error'); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back fails', async () => { + mockProvider.deleteChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await deleteChecklistItem('item1', 'checkItem1'); + + expect(result.status).toBe('deleted'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); + }); + + it('surfaces the provider-supplied updatedAt when present', async () => { + mockProvider.deleteChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2025-12-01T01:02:03.000Z', + }), ); + + const result = await deleteChecklistItem('PROJ-42', 'sub-48'); + + expect(result.updatedAt).toBe('2025-12-01T01:02:03.000Z'); + expect(result.workItemUrl).toBe('https://jira.example.com/browse/PROJ-42'); }); }); diff --git a/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts new file mode 100644 index 000000000..22b7d958a --- /dev/null +++ b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; + +const mockProvider = createMockPMProvider(); + +vi.mock('../../../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(() => mockProvider), +})); + +import { readWorkItemContext } from '../../../../../src/gadgets/pm/core/readWorkItemContext.js'; + +const FROZEN_NOW = new Date('2026-03-15T12:34:56.789Z'); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FROZEN_NOW); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('readWorkItemContext', () => { + it('returns the provider URL + updatedAt when getWorkItem succeeds', async () => { + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-02-01T01:02:03.000Z', + }), + ); + + const result = await readWorkItemContext('item1'); + + expect(result).toEqual({ + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-02-01T01:02:03.000Z', + }); + }); + + it('synthesises the current ISO timestamp when the provider omits updatedAt', async () => { + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: undefined, + }), + ); + + const result = await readWorkItemContext('item1'); + + expect(result.updatedAt).toBe(FROZEN_NOW.toISOString()); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back throws', async () => { + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await readWorkItemContext('item1'); + + expect(result).toEqual({ + workItemUrl: 'https://fallback.example/item1', + updatedAt: FROZEN_NOW.toISOString(), + }); + }); + + it('never propagates the read-back exception (mutation must remain successful)', async () => { + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/x'); + + await expect(readWorkItemContext('x')).resolves.toBeDefined(); + }); +}); diff --git a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts index da22e30ef..8c40c363a 100644 --- a/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateChecklistItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,38 +10,89 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateChecklistItem } from '../../../../../src/gadgets/pm/core/updateChecklistItem.js'; +beforeEach(() => { + vi.clearAllMocks(); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); + describe('updateChecklistItem', () => { - it('marks a checklist item as complete', async () => { + it('marks a checklist item as complete and returns the structured result', async () => { mockProvider.updateChecklistItem.mockResolvedValue(undefined); const result = await updateChecklistItem('item1', 'checkItem1', true); expect(mockProvider.updateChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1', true); - expect(result).toBe('Checklist item checkItem1 marked complete on work item item1'); + expect(result).toEqual({ + status: 'updated', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + complete: true, + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('marks a checklist item as incomplete', async () => { + it('marks a checklist item as incomplete and returns the structured result', async () => { mockProvider.updateChecklistItem.mockResolvedValue(undefined); const result = await updateChecklistItem('item1', 'checkItem1', false); expect(mockProvider.updateChecklistItem).toHaveBeenCalledWith('item1', 'checkItem1', false); - expect(result).toBe('Checklist item checkItem1 marked incomplete on work item item1'); + expect(result).toEqual({ + status: 'updated', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + checkItemId: 'checkItem1', + complete: false, + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); - it('throws an error message on failure', async () => { + it('throws when the provider mutation fails (no prose sentinel)', async () => { mockProvider.updateChecklistItem.mockRejectedValue(new Error('API error')); - await expect(updateChecklistItem('item1', 'checkItem1', true)).rejects.toThrow( - 'Error updating checklist item: API error', - ); + await expect(updateChecklistItem('item1', 'checkItem1', true)).rejects.toThrow('API error'); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values as-is', async () => { mockProvider.updateChecklistItem.mockRejectedValue('string error'); - await expect(updateChecklistItem('item1', 'ci1', false)).rejects.toThrow( - 'Error updating checklist item: string error', + await expect(updateChecklistItem('item1', 'ci1', false)).rejects.toBe('string error'); + }); + + it('falls back to getWorkItemUrl + synthesised timestamp when read-back fails', async () => { + mockProvider.updateChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await updateChecklistItem('item1', 'checkItem1', true); + + expect(result.status).toBe('updated'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + expect(result.updatedAt.length).toBeGreaterThan(0); + }); + + it('surfaces the provider-supplied updatedAt when present', async () => { + mockProvider.updateChecklistItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'PROJ-42', + url: 'https://jira.example.com/browse/PROJ-42', + updatedAt: '2025-12-01T01:02:03.000Z', + }), ); + + const result = await updateChecklistItem('PROJ-42', 'sub-1', true); + + expect(result.updatedAt).toBe('2025-12-01T01:02:03.000Z'); + expect(result.workItemUrl).toBe('https://jira.example.com/browse/PROJ-42'); }); }); From 5a60bb4afef0ca8c201b76fe42d220557e0a97b5 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 1 Jun 2026 16:08:01 +0200 Subject: [PATCH 04/25] feat(pm): structured outputs for work-item and comment mutations (MNG-1423) (#1389) Co-authored-by: Cascade Bot --- src/gadgets/pm/CreateWorkItem.ts | 16 +- src/gadgets/pm/MoveWorkItem.ts | 23 +- src/gadgets/pm/PostComment.ts | 8 +- src/gadgets/pm/UpdateWorkItem.ts | 27 +- src/gadgets/pm/core/createWorkItem.ts | 34 ++- src/gadgets/pm/core/moveWorkItem.ts | 91 +++++- src/gadgets/pm/core/mutationResults.ts | 127 ++++++++ src/gadgets/pm/core/postComment.ts | 92 ++++-- src/gadgets/pm/core/readWorkItemContext.ts | 20 +- src/gadgets/pm/core/updateWorkItem.ts | 95 ++++-- .../gadgets/pm/core/createWorkItem.test.ts | 117 ++++++-- .../unit/gadgets/pm/core/moveWorkItem.test.ts | 113 ++++--- .../unit/gadgets/pm/core/postComment.test.ts | 91 +++--- .../pm/core/readWorkItemContext.test.ts | 4 +- .../gadgets/pm/core/updateWorkItem.test.ts | 186 ++++++++---- tests/unit/gadgets/pm/wrappers.test.ts | 284 ++++++++++++++++++ 16 files changed, 1074 insertions(+), 254 deletions(-) create mode 100644 tests/unit/gadgets/pm/wrappers.test.ts diff --git a/src/gadgets/pm/CreateWorkItem.ts b/src/gadgets/pm/CreateWorkItem.ts index 883c6c65f..da3b06712 100644 --- a/src/gadgets/pm/CreateWorkItem.ts +++ b/src/gadgets/pm/CreateWorkItem.ts @@ -1,11 +1,17 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { createWorkItem } from './core/createWorkItem.js'; import { createWorkItemDef } from './definitions.js'; export const CreateWorkItem = createGadgetClass(createWorkItemDef, async (params) => { - return createWorkItem({ - containerId: params.containerId as string, - title: params.title as string, - description: params.description as string | undefined, - }); + try { + const result = await createWorkItem({ + containerId: params.containerId as string, + title: params.title as string, + description: params.description as string | undefined, + }); + return `Work item created successfully: "${result.title}" [id: ${result.id}] - ${result.url}`; + } catch (error) { + return formatGadgetError('creating work item', error); + } }); diff --git a/src/gadgets/pm/MoveWorkItem.ts b/src/gadgets/pm/MoveWorkItem.ts index e6c99f769..29c39347e 100644 --- a/src/gadgets/pm/MoveWorkItem.ts +++ b/src/gadgets/pm/MoveWorkItem.ts @@ -1,10 +1,25 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { moveWorkItem } from './core/moveWorkItem.js'; import { moveWorkItemDef } from './definitions.js'; export const MoveWorkItem = createGadgetClass(moveWorkItemDef, async (params) => { - return moveWorkItem({ - workItemId: params.workItemId as string, - destination: params.destination as string, - }); + try { + const result = await moveWorkItem({ + workItemId: params.workItemId as string, + destination: params.destination as string, + expectedSourceState: params.expectedSourceState as string | undefined, + }); + + switch (result.status) { + case 'moved': + return `Work item ${result.id} moved to ${result.destination} successfully`; + case 'noop': + return result.message ?? `Work item ${result.id} already in destination state — no-op`; + case 'aborted': + return result.message ?? `Aborted move of work item ${result.id}`; + } + } catch (error) { + return formatGadgetError('moving work item', error); + } }); diff --git a/src/gadgets/pm/PostComment.ts b/src/gadgets/pm/PostComment.ts index 95edbcb1c..a1f1df761 100644 --- a/src/gadgets/pm/PostComment.ts +++ b/src/gadgets/pm/PostComment.ts @@ -1,7 +1,13 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { postComment } from './core/postComment.js'; import { postCommentDef } from './definitions.js'; export const PostComment = createGadgetClass(postCommentDef, async (params) => { - return postComment(params.workItemId as string, params.text as string); + try { + await postComment(params.workItemId as string, params.text as string); + return 'Comment posted successfully'; + } catch (error) { + return formatGadgetError('posting comment', error); + } }); diff --git a/src/gadgets/pm/UpdateWorkItem.ts b/src/gadgets/pm/UpdateWorkItem.ts index 5e2ceb28a..b39c7b023 100644 --- a/src/gadgets/pm/UpdateWorkItem.ts +++ b/src/gadgets/pm/UpdateWorkItem.ts @@ -1,12 +1,27 @@ import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; import { updateWorkItem } from './core/updateWorkItem.js'; import { updateWorkItemDef } from './definitions.js'; export const UpdateWorkItem = createGadgetClass(updateWorkItemDef, async (params) => { - return updateWorkItem({ - workItemId: params.workItemId as string, - title: params.title as string | undefined, - description: params.description as string | undefined, - addLabelIds: params.addLabelId as string[] | undefined, - }); + try { + const result = await updateWorkItem({ + workItemId: params.workItemId as string, + title: params.title as string | undefined, + description: params.description as string | undefined, + addLabelIds: params.addLabelId as string[] | undefined, + }); + + if (result.status === 'noop') { + return result.message ?? 'Nothing to update - provide title, description, or labels'; + } + + const updated: string[] = [...result.changedFields]; + if (result.addedLabelIds.length > 0) { + updated.push(`${result.addedLabelIds.length} label(s)`); + } + return `Work item updated: ${updated.join(', ')}`; + } catch (error) { + return formatGadgetError('updating work item', error); + } }); diff --git a/src/gadgets/pm/core/createWorkItem.ts b/src/gadgets/pm/core/createWorkItem.ts index ebe66108f..9fa9d74f4 100644 --- a/src/gadgets/pm/core/createWorkItem.ts +++ b/src/gadgets/pm/core/createWorkItem.ts @@ -1,4 +1,5 @@ import { getPMProvider } from '../../../pm/index.js'; +import { pickTimestamp, type WorkItemCreatedResult } from './mutationResults.js'; export interface CreateWorkItemParams { containerId: string; @@ -6,12 +7,41 @@ export interface CreateWorkItemParams { description?: string; } -export async function createWorkItem(params: CreateWorkItemParams): Promise { +/** + * Create a work item in a container (Trello list / JIRA project / Linear + * team). + * + * Returns a structured `WorkItemCreatedResult` so downstream consumers can + * branch on shape rather than parsing prose. The result carries the + * freshly-created work-item identity (`id`, `title`, `url`), the action + * status (`'created'`), a provider-preferred `updatedAt`, and any + * workflow-state fields the provider surfaced (`workflowStatus`, + * `workflowStatusId`). Each provider populates these fields opportunistically + * — JIRA's create endpoint does not echo a status, while Trello returns the + * destination list ID and Linear surfaces the workflow state name via + * `WorkItem.status`. + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. The previous prose-returning contract was + * lossy — consumers had to regex out `[id: ...]` and `https://...` from the + * sentence to act on the result. + */ +export async function createWorkItem(params: CreateWorkItemParams): Promise { const item = await getPMProvider().createWorkItem({ containerId: params.containerId, title: params.title, description: params.description, }); - return `Work item created successfully: "${item.title}" [id: ${item.id}] - ${item.url}`; + const result: WorkItemCreatedResult = { + status: 'created', + id: item.id, + title: item.title, + url: item.url, + updatedAt: pickTimestamp(item.updatedAt ?? item.createdAt), + }; + if (item.status) result.workflowStatus = item.status; + if (item.statusId) result.workflowStatusId = item.statusId; + return result; } diff --git a/src/gadgets/pm/core/moveWorkItem.ts b/src/gadgets/pm/core/moveWorkItem.ts index 2c3c3be02..25185fecb 100644 --- a/src/gadgets/pm/core/moveWorkItem.ts +++ b/src/gadgets/pm/core/moveWorkItem.ts @@ -1,5 +1,6 @@ import { getPMProvider } from '../../../pm/index.js'; import type { WorkItem } from '../../../pm/types.js'; +import { currentTimestamp, pickTimestamp, type WorkItemMovedResult } from './mutationResults.js'; export interface MoveWorkItemParams { workItemId: string; @@ -43,34 +44,94 @@ function formatCurrentStatus(current: WorkItem): string { return `${current.status ?? 'unknown'} (${current.statusId})`; } -async function guardedMove(params: MoveWorkItemParams): Promise { +/** + * Build the previous-status fields for guarded outcomes. Keeps the result + * keys consistent across `'noop'` / `'aborted'` / `'moved'` returns from the + * guarded path. + */ +function buildPreviousStatusFields(current: WorkItem): { + previousStatus?: string; + previousStatusId?: string; +} { + const fields: { previousStatus?: string; previousStatusId?: string } = {}; + if (current.status) fields.previousStatus = current.status; + if (current.statusId) fields.previousStatusId = current.statusId; + return fields; +} + +async function guardedMove(params: MoveWorkItemParams): Promise { const provider = getPMProvider(); const current = await provider.getWorkItem(params.workItemId); const expected = normalizeStatus(params.expectedSourceState); const destination = normalizeStatus(params.destination); + const previousStatusFields = buildPreviousStatusFields(current); if (isAlreadyInDestination(current, destination)) { - return `Work item ${params.workItemId} already in destination state '${current.status ?? current.statusId}' — no-op`; + return { + status: 'noop', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(current.updatedAt), + ...previousStatusFields, + message: `Work item already in destination state '${current.status ?? current.statusId}' — no-op`, + }; } if (!matchesExpectedSource(current, expected)) { - return `Aborted: work item ${params.workItemId} is in '${formatCurrentStatus(current)}', expected '${params.expectedSourceState}' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)`; + return { + status: 'aborted', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: currentTimestamp(), + ...previousStatusFields, + message: `Aborted: work item is in '${formatCurrentStatus(current)}', expected '${params.expectedSourceState}' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)`, + }; } await provider.moveWorkItem(params.workItemId, params.destination); - return `Work item ${params.workItemId} moved to ${params.destination} successfully`; + return { + status: 'moved', + id: params.workItemId, + url: current.url || provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(undefined), + ...previousStatusFields, + }; } -export async function moveWorkItem(params: MoveWorkItemParams): Promise { - try { - if (params.expectedSourceState !== undefined) { - return await guardedMove(params); - } - - await getPMProvider().moveWorkItem(params.workItemId, params.destination); - return `Work item ${params.workItemId} moved to ${params.destination} successfully`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error moving work item: ${message}`); +/** + * Move a work item to a different list or status. + * + * Returns a structured `WorkItemMovedResult` so downstream consumers can + * branch on shape rather than parsing prose. Three outcomes: + * - `'moved'` — the provider accepted the move. + * - `'noop'` — the work item was already in the destination (guarded + * path only). + * - `'aborted'` — the work item was in an unexpected source state and the + * guarded path refused the move. + * + * `expectedSourceState` is the parallel-agent race guard introduced for the + * MNG-538 incident (2026-05-06). When provided, the gadget fetches the work + * item's current status and aborts unless it matches (case-insensitive). + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. + */ +export async function moveWorkItem(params: MoveWorkItemParams): Promise { + if (params.expectedSourceState !== undefined) { + return guardedMove(params); } + + const provider = getPMProvider(); + await provider.moveWorkItem(params.workItemId, params.destination); + return { + status: 'moved', + id: params.workItemId, + url: provider.getWorkItemUrl(params.workItemId), + destination: params.destination, + updatedAt: pickTimestamp(undefined), + }; } diff --git a/src/gadgets/pm/core/mutationResults.ts b/src/gadgets/pm/core/mutationResults.ts index b2fb69819..697176a10 100644 --- a/src/gadgets/pm/core/mutationResults.ts +++ b/src/gadgets/pm/core/mutationResults.ts @@ -152,6 +152,133 @@ export function abortedResult(args: { return result; } +// ─── Work-item & comment mutation result contracts (MNG-1423) ─────────────── +// +// Work-item and comment mutations expose action-specific outcome statuses +// alongside the parent work-item identity / URL / timestamp. They live in this +// shared module so consumers can import all PM mutation result shapes from a +// single surface; `pickTimestamp` / `currentTimestamp` above are reused as-is. +// +// The acceptance criteria from MNG-1423 use action-specific status literals +// (`created`, `updated`, `moved`, `noop`, `aborted`) instead of the generic +// `'ok' | 'no-op' | 'aborted'` union that the original PM mutation contract +// shipped. The earlier union (re-exported above for parity with the GitHub +// mutation contract) is still useful to callers building their own mutations +// — the explicit literal unions below are what the four work-item / comment +// cores return. + +/** + * Result returned by `createWorkItem`. Surfaces the freshly-created work item's + * identity (`id`, `title`, `url`), the action status (`'created'`), + * provider-preferred `updatedAt`, and any workflow-state fields the provider + * surfaced on creation (`status`, `statusId`). The optional state fields are + * provider-dependent — Trello returns the destination list ID via `status`, + * Linear returns the workflow state via `statusId`, JIRA's create endpoint + * does not surface a status on the create response. + */ +export interface WorkItemCreatedResult { + status: 'created'; + id: string; + title: string; + url: string; + updatedAt: string; + /** Optional human-readable workflow state name (e.g. Linear state name). */ + workflowStatus?: string; + /** Optional native workflow state ID (e.g. Linear state UUID, Trello list ID). */ + workflowStatusId?: string; +} + +/** + * Result returned by `updateWorkItem`. Two outcomes: + * - `'updated'` — the provider accepted at least one field update or label + * addition. `changedFields` lists the work-item fields that were sent + * (any of `'title'` / `'description'`); `addedLabelIds` lists the labels + * that were applied. The current work-item metadata (`title`, `url`, + * `updatedAt`) is read back from the provider after the mutation. + * - `'noop'` — the caller did not pass any updates (no title, + * description, or labels). No provider write happened; `updatedAt` is + * synthesised via `currentTimestamp()` and `title` / `url` are best-effort + * (read back from the provider when available). + * + * `changedFields` and `addedLabelIds` are always present (as arrays) so + * consumers never branch on `undefined`. They may be empty on the `'noop'` + * outcome. + */ +export interface WorkItemUpdatedResult { + status: 'updated' | 'noop'; + id: string; + title: string; + url: string; + updatedAt: string; + changedFields: Array<'title' | 'description'>; + addedLabelIds: string[]; + /** Optional human-readable note explaining the outcome (used on `noop`). */ + message?: string; +} + +/** + * Result returned by `moveWorkItem`. Three outcomes: + * - `'moved'` — the provider accepted the move from the caller's source + * into the requested destination. The new workflow state is reflected in + * `destination` (the value passed to the provider). + * - `'noop'` — the work item was already in the requested destination + * (idempotent guard via `expectedSourceState`). No provider write + * happened. + * - `'aborted'` — the work item's current status did not match + * `expectedSourceState` (parallel-agent race guard). No provider write + * happened. + * + * The work-item `url` is sourced via `provider.getWorkItemUrl(id)` (or the + * read-back `WorkItem.url` when the guarded path already fetched it). The + * `previousStatus` field surfaces the work-item's current human-readable + * workflow status / status ID when the guarded path read it back from the + * provider — useful for diagnostics on `'noop'` and `'aborted'` outcomes. + */ +export interface WorkItemMovedResult { + status: 'moved' | 'noop' | 'aborted'; + id: string; + url: string; + destination: string; + updatedAt: string; + /** + * The work item's current status / status ID at the time of the guarded + * read-back. Present for `'noop'` and `'aborted'` outcomes (and for + * `'moved'` outcomes that went through the guarded path); omitted for + * `'moved'` outcomes that bypassed the guard (no `expectedSourceState`). + */ + previousStatus?: string; + /** + * The previousStatus's native ID when known (e.g. Linear state UUID, + * Trello list ID). Optional; consumers can fall back to `previousStatus`. + */ + previousStatusId?: string; + /** Optional human-readable note explaining the outcome. */ + message?: string; +} + +/** + * Result returned by `postComment`. Two outcomes: + * - `'created'` — a new comment was added via `provider.addComment`. `id` + * is the new comment's provider ID. + * - `'updated'` — an existing progress comment was replaced via + * `provider.updateComment`. `id` is the existing comment's provider ID. + * + * The parent work-item context (`workItemId`, `workItemUrl`) is always + * present so downstream consumers can correlate the comment back to its + * parent. `updatedAt` reflects when the comment was written; because the + * `PMProvider.addComment` / `updateComment` surface returns only an ID + * (not the full comment record), we synthesise the timestamp via + * `currentTimestamp()` — the comment was just written, so the synthetic + * "now" closely tracks the provider-side reality. + */ +export interface CommentPostedResult { + status: 'created' | 'updated'; + id: string; + workItemId: string; + workItemUrl: string; + updatedAt: string; +} + // ─── Checklist mutation result contracts (MNG-1424) ───────────────────────── // // PM checklist mutations have action-specific outcome statuses (`created`, diff --git a/src/gadgets/pm/core/postComment.ts b/src/gadgets/pm/core/postComment.ts index 836e532d4..19b7abf29 100644 --- a/src/gadgets/pm/core/postComment.ts +++ b/src/gadgets/pm/core/postComment.ts @@ -2,37 +2,73 @@ import { clearProgressCommentId, readProgressCommentId } from '../../../backends import { getPMProvider } from '../../../pm/index.js'; import { logger } from '../../../utils/logging.js'; import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; +import { type CommentPostedResult, currentTimestamp } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; -export async function postComment(workItemId: string, text: string): Promise { - try { - const provider = getPMProvider(); +/** + * Post a comment on a work item, or replace the progress-comment when one was + * pre-seeded for this work item. + * + * Returns a structured `CommentPostedResult` so downstream consumers can + * branch on shape rather than parsing prose. Two outcomes: + * - `'created'` — a new comment was added via `provider.addComment`. + * - `'updated'` — the existing progress comment (id read from + * `CASCADE_PROGRESS_COMMENT_ID`) was replaced via + * `provider.updateComment`. Falls back to `'created'` if the update fails + * so a stale progress-comment id never blocks a real comment. + * + * The result carries the parent work-item context (`workItemId`, + * `workItemUrl`) so downstream consumers can correlate the comment back to + * its parent without re-parsing IDs. + * + * `updatedAt` is synthesised via `currentTimestamp()` because the + * `PMProvider.addComment` / `updateComment` interface returns only an ID, + * not the full comment record. The synthetic "now" closely tracks the + * provider-side write (the call just returned). + * + * Runtime provider errors propagate (no internal try/catch wrapping) per the + * MNG-1423 contract — the CLI factory wraps thrown errors in the spec-014 + * `runtime` envelope and gadget wrappers can wrap with `formatGadgetError`. + */ +export async function postComment(workItemId: string, text: string): Promise { + const provider = getPMProvider(); - // Append run link footer when enabled via env vars (injected by secretBuilder for subprocesses) - const runLinkFooter = buildRunLinkFooterFromEnv(workItemId); - const fullText = runLinkFooter ? text + runLinkFooter : text; + // Append run link footer when enabled via env vars (injected by secretBuilder for subprocesses) + const runLinkFooter = buildRunLinkFooterFromEnv(workItemId); + const fullText = runLinkFooter ? text + runLinkFooter : text; - // Check if there is a progress comment we should update instead of creating new - const progressState = readProgressCommentId(); - if (progressState && progressState.workItemId === workItemId) { - try { - await provider.updateComment(workItemId, progressState.commentId, fullText); - clearProgressCommentId(); - return 'Comment posted successfully'; - } catch (error) { - // Fall back to creating a new comment if update fails - logger.warn('Failed to update progress comment, creating new one', { - workItemId, - commentId: progressState.commentId, - error: error instanceof Error ? error.message : String(error), - }); - clearProgressCommentId(); - } + // Check if there is a progress comment we should update instead of creating new + const progressState = readProgressCommentId(); + if (progressState && progressState.workItemId === workItemId) { + try { + await provider.updateComment(workItemId, progressState.commentId, fullText); + clearProgressCommentId(); + const { workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'updated', + id: progressState.commentId, + workItemId, + workItemUrl, + updatedAt: currentTimestamp(), + }; + } catch (error) { + // Fall back to creating a new comment if update fails + logger.warn('Failed to update progress comment, creating new one', { + workItemId, + commentId: progressState.commentId, + error: error instanceof Error ? error.message : String(error), + }); + clearProgressCommentId(); } - - await provider.addComment(workItemId, fullText); - return 'Comment posted successfully'; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error posting comment: ${message}`); } + + const commentId = await provider.addComment(workItemId, fullText); + const { workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'created', + id: commentId, + workItemId, + workItemUrl, + updatedAt: currentTimestamp(), + }; } diff --git a/src/gadgets/pm/core/readWorkItemContext.ts b/src/gadgets/pm/core/readWorkItemContext.ts index ef86e617d..3dbc3e74f 100644 --- a/src/gadgets/pm/core/readWorkItemContext.ts +++ b/src/gadgets/pm/core/readWorkItemContext.ts @@ -2,9 +2,10 @@ import { getPMProvider } from '../../../pm/index.js'; import { pickTimestamp } from './mutationResults.js'; /** - * Shared read-back helper used by PM checklist mutation cores - * (`addChecklist`, `updateChecklistItem`, `deleteChecklistItem`) to surface - * the parent work-item's URL + `updatedAt` on the structured result. + * Shared read-back helper used by PM mutation cores (`addChecklist`, + * `updateChecklistItem`, `deleteChecklistItem`, `updateWorkItem`, + * `postComment`) to surface the parent work-item's URL + `updatedAt` (and, + * for callers that need it, `title`) on the structured result. * * Implements the technical-notes pattern from MNG-1424: "Use work-item * read-back for URL/status/timestamp where provider APIs do not return deep @@ -18,17 +19,22 @@ import { pickTimestamp } from './mutationResults.js'; * duplicates rows. We therefore swallow the read-back error and fall back to * the synchronous `getWorkItemUrl(id)` constructor plus a synthesised current * ISO timestamp. The mutation success is preserved; the timestamp is just - * synthesised rather than provider-supplied. + * synthesised rather than provider-supplied. `title` is `undefined` on the + * fallback path because the synchronous `getWorkItemUrl` surface only returns + * a URL. */ -export async function readWorkItemContext( - workItemId: string, -): Promise<{ workItemUrl: string; updatedAt: string }> { +export async function readWorkItemContext(workItemId: string): Promise<{ + workItemUrl: string; + updatedAt: string; + title?: string; +}> { const provider = getPMProvider(); try { const item = await provider.getWorkItem(workItemId); return { workItemUrl: item.url, updatedAt: pickTimestamp(item.updatedAt), + title: item.title, }; } catch { return { diff --git a/src/gadgets/pm/core/updateWorkItem.ts b/src/gadgets/pm/core/updateWorkItem.ts index 981fc09b1..0a9f865cf 100644 --- a/src/gadgets/pm/core/updateWorkItem.ts +++ b/src/gadgets/pm/core/updateWorkItem.ts @@ -1,4 +1,6 @@ import { getPMProvider } from '../../../pm/index.js'; +import { currentTimestamp, type WorkItemUpdatedResult } from './mutationResults.js'; +import { readWorkItemContext } from './readWorkItemContext.js'; export interface UpdateWorkItemParams { workItemId: string; @@ -7,35 +9,80 @@ export interface UpdateWorkItemParams { addLabelIds?: string[]; } -export async function updateWorkItem(params: UpdateWorkItemParams): Promise { - if (!params.title && !params.description && !params.addLabelIds?.length) { - return 'Nothing to update - provide title, description, or labels'; +/** + * Update a work item — any of `title`, `description`, or `addLabelIds`. Title + * and description are sent in one provider call; label additions go through + * `provider.addLabel` per label. + * + * Returns a structured `WorkItemUpdatedResult` so downstream consumers can + * branch on shape rather than parsing prose. Two outcomes: + * - `'updated'` — at least one field or label was sent to the provider. + * `changedFields` lists the fields written; `addedLabelIds` echoes the + * applied label IDs. The current work-item metadata (`title`, `url`, + * `updatedAt`) is read back from the provider after the mutation so + * consumers see the post-write state. + * - `'noop'` — the caller passed no updates. No provider write happens; + * the result still surfaces the work-item identity (best-effort URL + + * synthesised timestamp) so consumers can correlate the call back to a + * work item. + * + * Runtime provider errors propagate (no internal try/catch) so the CLI + * factory emits the spec-014 `runtime` envelope and gadget wrappers can wrap + * with `formatGadgetError`. Read-back failures after a successful mutation + * fall back to a synthesised URL + timestamp rather than masking the mutation + * success (delegated to `readWorkItemContext`). + */ +export async function updateWorkItem(params: UpdateWorkItemParams): Promise { + const provider = getPMProvider(); + const hasTitle = Boolean(params.title); + const hasDescription = Boolean(params.description); + const labelIds = params.addLabelIds ?? []; + const hasLabels = labelIds.length > 0; + + if (!hasTitle && !hasDescription && !hasLabels) { + return buildNoopResult(params.workItemId); } - try { - const provider = getPMProvider(); + if (hasTitle || hasDescription) { + await provider.updateWorkItem(params.workItemId, { + title: params.title, + description: params.description, + }); + } - if (params.title || params.description) { - await provider.updateWorkItem(params.workItemId, { - title: params.title, - description: params.description, - }); + if (hasLabels) { + for (const labelId of labelIds) { + await provider.addLabel(params.workItemId, labelId); } + } - if (params.addLabelIds?.length) { - for (const labelId of params.addLabelIds) { - await provider.addLabel(params.workItemId, labelId); - } - } + const changedFields: Array<'title' | 'description'> = []; + if (hasTitle) changedFields.push('title'); + if (hasDescription) changedFields.push('description'); - const updated: string[] = []; - if (params.title) updated.push('title'); - if (params.description) updated.push('description'); - if (params.addLabelIds?.length) updated.push(`${params.addLabelIds.length} label(s)`); + const { title, workItemUrl, updatedAt } = await readWorkItemContext(params.workItemId); - return `Work item updated: ${updated.join(', ')}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Error updating work item: ${message}`); - } + return { + status: 'updated', + id: params.workItemId, + title: title ?? params.title ?? '', + url: workItemUrl, + updatedAt, + changedFields, + addedLabelIds: [...labelIds], + }; +} + +async function buildNoopResult(workItemId: string): Promise { + const { title, workItemUrl } = await readWorkItemContext(workItemId); + return { + status: 'noop', + id: workItemId, + title: title ?? '', + url: workItemUrl, + updatedAt: currentTimestamp(), + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }; } diff --git a/tests/unit/gadgets/pm/core/createWorkItem.test.ts b/tests/unit/gadgets/pm/core/createWorkItem.test.ts index 99bb22030..6a2f00277 100644 --- a/tests/unit/gadgets/pm/core/createWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/createWorkItem.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -11,14 +11,18 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { createWorkItem } from '../../../../../src/gadgets/pm/core/createWorkItem.js'; describe('createWorkItem', () => { - it('creates a work item and returns success message', async () => { - mockProvider.createWorkItem.mockResolvedValue({ - id: 'item1', - title: 'New Feature', - description: 'A new feature', - url: 'https://trello.com/c/item1', - labels: [], - }); + it('returns a structured WorkItemCreatedResult with provider metadata', async () => { + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + title: 'New Feature', + description: 'A new feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + createdAt: '2026-03-15T12:00:00.000Z', + labels: [], + }), + ); const result = await createWorkItem({ containerId: 'list1', @@ -31,31 +35,106 @@ describe('createWorkItem', () => { title: 'New Feature', description: 'A new feature', }); - expect(result).toBe( - 'Work item created successfully: "New Feature" [id: item1] - https://trello.com/c/item1', - ); + expect(result).toEqual({ + status: 'created', + id: 'item1', + title: 'New Feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); }); it('creates work item without description', async () => { - mockProvider.createWorkItem.mockResolvedValue({ + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item2', + title: 'Simple Item', + description: '', + url: 'https://trello.com/c/item2', + updatedAt: '2026-03-15T13:00:00.000Z', + createdAt: '2026-03-15T13:00:00.000Z', + labels: [], + }), + ); + + const result = await createWorkItem({ + containerId: 'list1', + title: 'Simple Item', + }); + + expect(result).toMatchObject({ + status: 'created', id: 'item2', title: 'Simple Item', - description: '', url: 'https://trello.com/c/item2', + }); + }); + + it('surfaces optional workflow-state fields when the provider returned them', async () => { + mockProvider.createWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'MNG-1', + title: 'Linear-like create', + description: '', + url: 'https://linear.app/team/issue/MNG-1', + updatedAt: '2026-03-15T14:00:00.000Z', + status: 'Backlog', + statusId: 'state-backlog-uuid', + }), + ); + + const result = await createWorkItem({ + containerId: 'team-x', + title: 'Linear-like create', + }); + + expect(result).toEqual({ + status: 'created', + id: 'MNG-1', + title: 'Linear-like create', + url: 'https://linear.app/team/issue/MNG-1', + updatedAt: '2026-03-15T14:00:00.000Z', + workflowStatus: 'Backlog', + workflowStatusId: 'state-backlog-uuid', + }); + }); + + it('falls back to createdAt when updatedAt is absent (creation-only providers)', async () => { + mockProvider.createWorkItem.mockResolvedValue({ + id: 'item-only-created', + title: 'Created-only timestamp', + description: '', + url: 'https://trello.com/c/item-only-created', labels: [], + createdAt: '2026-03-15T15:00:00.000Z', }); const result = await createWorkItem({ containerId: 'list1', - title: 'Simple Item', + title: 'Created-only timestamp', }); - expect(result).toBe( - 'Work item created successfully: "Simple Item" [id: item2] - https://trello.com/c/item2', - ); + expect(result.updatedAt).toBe('2026-03-15T15:00:00.000Z'); + }); + + it('synthesises a current ISO timestamp when the provider omits both timestamps', async () => { + mockProvider.createWorkItem.mockResolvedValue({ + id: 'item-no-ts', + title: 'No timestamps', + description: '', + url: 'https://trello.com/c/item-no-ts', + labels: [], + }); + + const result = await createWorkItem({ + containerId: 'list1', + title: 'No timestamps', + }); + + expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); - it('throws on failure instead of swallowing errors', async () => { + it('throws on failure instead of swallowing errors (no prose sentinel)', async () => { mockProvider.createWorkItem.mockRejectedValue(new Error('API error')); await expect( diff --git a/tests/unit/gadgets/pm/core/moveWorkItem.test.ts b/tests/unit/gadgets/pm/core/moveWorkItem.test.ts index a9a54f9e8..1daa050c7 100644 --- a/tests/unit/gadgets/pm/core/moveWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/moveWorkItem.test.ts @@ -13,40 +13,52 @@ import { moveWorkItem } from '../../../../../src/gadgets/pm/core/moveWorkItem.js describe('moveWorkItem', () => { beforeEach(() => { vi.clearAllMocks(); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/card1'); }); - it('calls provider.moveWorkItem with correct args and returns success message', async () => { - mockProvider.moveWorkItem.mockResolvedValue(undefined); + describe('unguarded path (no expectedSourceState)', () => { + it('returns a structured moved result on success', async () => { + mockProvider.moveWorkItem.mockResolvedValue(undefined); - const result = await moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }); + const result = await moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }); - expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); - expect(result).toBe('Work item card1 moved to list2 successfully'); - }); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); + expect(mockProvider.getWorkItem).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + }); + expect(typeof result.updatedAt).toBe('string'); + expect(result.previousStatus).toBeUndefined(); + expect(result.previousStatusId).toBeUndefined(); + }); - it('throws an error message on failure', async () => { - mockProvider.moveWorkItem.mockRejectedValue(new Error('API error')); + it('throws on provider failure (no prose sentinel)', async () => { + mockProvider.moveWorkItem.mockRejectedValue(new Error('API error')); - await expect( - moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }), - ).rejects.toThrow('Error moving work item: API error'); - }); + await expect( + moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }), + ).rejects.toThrow('API error'); + }); - it('handles non-Error throws', async () => { - mockProvider.moveWorkItem.mockRejectedValue('network timeout'); + it('propagates non-Error throws', async () => { + mockProvider.moveWorkItem.mockRejectedValue('network timeout'); - await expect( - moveWorkItem({ - workItemId: 'card1', - destination: 'list2', - }), - ).rejects.toThrow('Error moving work item: network timeout'); + await expect( + moveWorkItem({ + workItemId: 'card1', + destination: 'list2', + }), + ).rejects.toThrow('network timeout'); + }); }); // ── expectedSourceState guard ──────────────────────────────────────────── @@ -64,7 +76,7 @@ describe('moveWorkItem', () => { labels: [], }; - it('proceeds with move when current status matches expectedSourceState', async () => { + it('returns a moved result when current status matches expectedSourceState', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'Backlog', @@ -79,7 +91,13 @@ describe('moveWorkItem', () => { expect(mockProvider.getWorkItem).toHaveBeenCalledWith('MNG-538'); expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('MNG-538', 'todo-state-id'); - expect(result).toContain('moved'); + expect(result).toMatchObject({ + status: 'moved', + id: 'MNG-538', + url: 'https://linear.app/mongrel/issue/MNG-538', + destination: 'todo-state-id', + previousStatus: 'Backlog', + }); }); it('proceeds with move when current statusId matches expectedSourceState', async () => { @@ -97,10 +115,12 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('MNG-538', 'state-todo'); - expect(result).toContain('moved'); + expect(result.status).toBe('moved'); + expect(result.previousStatus).toBe('Ready'); + expect(result.previousStatusId).toBe('state-backlog'); }); - it('aborts move when current status differs from expectedSourceState', async () => { + it('returns aborted result when current status differs from expectedSourceState', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'In Progress', @@ -113,12 +133,18 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toMatch(/Aborted|aborted|skipped/); - expect(result).toContain('In Progress'); - expect(result).toContain('Backlog'); + expect(result).toMatchObject({ + status: 'aborted', + id: 'MNG-538', + url: 'https://linear.app/mongrel/issue/MNG-538', + destination: 'todo-state-id', + previousStatus: 'In Progress', + }); + expect(result.message).toContain('In Progress'); + expect(result.message).toContain('Backlog'); }); - it('aborts move when Linear issue is in an unmapped Ideas statusId', async () => { + it('aborts when Linear issue is in an unmapped Ideas statusId', async () => { mockProvider.getWorkItem.mockResolvedValue({ ...baseItem, status: 'Ideas', @@ -132,8 +158,11 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toContain('Ideas (state-ideas)'); - expect(result).toContain('state-backlog'); + expect(result.status).toBe('aborted'); + expect(result.previousStatus).toBe('Ideas'); + expect(result.previousStatusId).toBe('state-ideas'); + expect(result.message).toContain('Ideas (state-ideas)'); + expect(result.message).toContain('state-backlog'); }); it('matches expectedSourceState case-insensitively (Linear vs Trello casing drift)', async () => { @@ -150,10 +179,10 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).toHaveBeenCalled(); - expect(result).toContain('moved'); + expect(result.status).toBe('moved'); }); - it('skips silently when current status is already the destination (idempotency)', async () => { + it('returns noop when current status is already the destination (idempotency)', async () => { // expectedSourceState matches but current status equals destination — // rare race where a parallel agent already moved the item. Treat as // no-op rather than firing a redundant Linear API call. @@ -169,7 +198,9 @@ describe('moveWorkItem', () => { }); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); - expect(result).toMatch(/already|no-op|aborted/i); + expect(result.status).toBe('noop'); + expect(result.previousStatus).toBe('Todo'); + expect(result.message).toMatch(/already|no-op/i); }); it('does NOT call getWorkItem when expectedSourceState is omitted (back-compat)', async () => { @@ -184,7 +215,7 @@ describe('moveWorkItem', () => { expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('card1', 'list2'); }); - it('throws a structured error if getWorkItem throws', async () => { + it('throws when guarded read-back throws (no prose sentinel)', async () => { mockProvider.getWorkItem.mockRejectedValue(new Error('API down')); await expect( @@ -193,7 +224,7 @@ describe('moveWorkItem', () => { destination: 'todo-state-id', expectedSourceState: 'Backlog', }), - ).rejects.toThrow('Error moving work item: API down'); + ).rejects.toThrow('API down'); expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); diff --git a/tests/unit/gadgets/pm/core/postComment.test.ts b/tests/unit/gadgets/pm/core/postComment.test.ts index 8618d13a2..e8ff23765 100644 --- a/tests/unit/gadgets/pm/core/postComment.test.ts +++ b/tests/unit/gadgets/pm/core/postComment.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -36,28 +36,39 @@ const mockLogger = vi.mocked(logger); beforeEach(() => { mockReadProgressCommentId.mockReturnValue(null); + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + url: 'https://trello.com/c/item1', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); }); describe('postComment', () => { - it('posts a comment and returns success message', async () => { - mockProvider.addComment.mockResolvedValue(undefined); + it('returns a structured created result when no progress comment exists', async () => { + mockProvider.addComment.mockResolvedValue('comment-new'); const result = await postComment('item1', 'Hello world'); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'Hello world'); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'created', + id: 'comment-new', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); + expect(typeof result.updatedAt).toBe('string'); }); - it('throws an error message on failure', async () => { + it('throws on provider failure (no prose sentinel)', async () => { mockProvider.addComment.mockRejectedValue(new Error('Network error')); - await expect(postComment('item1', 'text')).rejects.toThrow( - 'Error posting comment: Network error', - ); + await expect(postComment('item1', 'text')).rejects.toThrow('Network error'); }); - it('passes multi-line text correctly', async () => { - mockProvider.addComment.mockResolvedValue(undefined); + it('passes multi-line text through unchanged', async () => { + mockProvider.addComment.mockResolvedValue('comment-multi'); const text = 'Line 1\n\nLine 2\n\nLine 3'; await postComment('item1', text); @@ -65,16 +76,25 @@ describe('postComment', () => { expect(mockProvider.addComment).toHaveBeenCalledWith('item1', text); }); - it('handles non-Error thrown value', async () => { + it('propagates non-Error thrown values', async () => { mockProvider.addComment.mockRejectedValue('string error'); - await expect(postComment('item1', 'text')).rejects.toThrow( - 'Error posting comment: string error', - ); + await expect(postComment('item1', 'text')).rejects.toThrow('string error'); + }); + + it('falls back to getWorkItemUrl when read-back fails', async () => { + mockProvider.addComment.mockResolvedValue('comment-fallback'); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); + + const result = await postComment('item1', 'text'); + + expect(result.status).toBe('created'); + expect(result.workItemUrl).toBe('https://fallback.example/item1'); }); describe('progress comment replacement', () => { - it('updates existing progress comment when state matches workItemId', async () => { + it('returns an updated result when existing progress comment is replaced', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockResolvedValue(undefined); @@ -87,26 +107,33 @@ describe('postComment', () => { ); expect(mockProvider.addComment).not.toHaveBeenCalled(); expect(mockClearProgressCommentId).toHaveBeenCalled(); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'updated', + id: 'comment-42', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); }); - it('does not update when workItemId does not match state', async () => { + it('does not update when workItemId does not match progress state', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'other-item', commentId: 'comment-42', }); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); - await postComment('item1', 'My comment'); + const result = await postComment('item1', 'My comment'); expect(mockProvider.updateComment).not.toHaveBeenCalled(); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'My comment'); + expect(result.status).toBe('created'); + expect(result.id).toBe('comment-new'); }); - it('falls back to addComment when updateComment fails, and clears state', async () => { + it('falls back to addComment when updateComment fails and surfaces a created result', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockRejectedValue(new Error('Comment not found')); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); const result = await postComment('item1', 'Final summary'); @@ -125,24 +152,18 @@ describe('postComment', () => { ); expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'Final summary'); expect(mockClearProgressCommentId).toHaveBeenCalled(); - expect(result).toBe('Comment posted successfully'); - }); - - it('creates new comment (no state) when no progress comment exists', async () => { - mockReadProgressCommentId.mockReturnValue(null); - mockProvider.addComment.mockResolvedValue(undefined); - - const result = await postComment('item1', 'New comment'); - - expect(mockProvider.updateComment).not.toHaveBeenCalled(); - expect(mockProvider.addComment).toHaveBeenCalledWith('item1', 'New comment'); - expect(result).toBe('Comment posted successfully'); + expect(result).toMatchObject({ + status: 'created', + id: 'comment-new', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + }); }); - it('clears state before fallback so subsequent calls create new comments', async () => { + it('clears progress state before the fallback addComment runs', async () => { mockReadProgressCommentId.mockReturnValue({ workItemId: 'item1', commentId: 'comment-42' }); mockProvider.updateComment.mockRejectedValue(new Error('gone')); - mockProvider.addComment.mockResolvedValue(undefined); + mockProvider.addComment.mockResolvedValue('comment-new'); await postComment('item1', 'text'); diff --git a/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts index 22b7d958a..451763330 100644 --- a/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts +++ b/tests/unit/gadgets/pm/core/readWorkItemContext.test.ts @@ -23,10 +23,11 @@ afterEach(() => { }); describe('readWorkItemContext', () => { - it('returns the provider URL + updatedAt when getWorkItem succeeds', async () => { + it('returns the provider URL + updatedAt + title when getWorkItem succeeds', async () => { mockProvider.getWorkItem.mockResolvedValue( createMockWorkItem({ id: 'item1', + title: 'Mock work item', url: 'https://trello.com/c/item1', updatedAt: '2026-02-01T01:02:03.000Z', }), @@ -37,6 +38,7 @@ describe('readWorkItemContext', () => { expect(result).toEqual({ workItemUrl: 'https://trello.com/c/item1', updatedAt: '2026-02-01T01:02:03.000Z', + title: 'Mock work item', }); }); diff --git a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts index 12f889c4d..adda50aab 100644 --- a/tests/unit/gadgets/pm/core/updateWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/updateWorkItem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockPMProvider } from '../../../../helpers/mockPMProvider.js'; +import { createMockPMProvider, createMockWorkItem } from '../../../../helpers/mockPMProvider.js'; const mockProvider = createMockPMProvider(); @@ -10,91 +10,145 @@ vi.mock('../../../../../src/pm/index.js', () => ({ import { updateWorkItem } from '../../../../../src/gadgets/pm/core/updateWorkItem.js'; -describe('updateWorkItem', () => { - it('returns early message when nothing to update', async () => { - const result = await updateWorkItem({ workItemId: 'item1' }); - expect(result).toBe('Nothing to update - provide title, description, or labels'); - expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); - }); - - it('updates title only', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - - const result = await updateWorkItem({ workItemId: 'item1', title: 'New Title' }); +beforeEach(() => { + // Default work-item read-back for the post-mutation metadata fetch. + mockProvider.getWorkItem.mockResolvedValue( + createMockWorkItem({ + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }), + ); + mockProvider.getWorkItemUrl.mockReturnValue('https://trello.com/c/item1'); +}); - expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { - title: 'New Title', - description: undefined, +describe('updateWorkItem', () => { + describe('noop path (nothing to update)', () => { + it('returns a structured noop result when no fields are provided', async () => { + const result = await updateWorkItem({ workItemId: 'item1' }); + expect(result).toMatchObject({ + status: 'noop', + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }); + expect(typeof result.updatedAt).toBe('string'); + expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); + expect(mockProvider.addLabel).not.toHaveBeenCalled(); }); - expect(result).toBe('Work item updated: title'); - }); - - it('updates description only', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - - const result = await updateWorkItem({ workItemId: 'item1', description: 'New description' }); - expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { - title: undefined, - description: 'New description', + it('returns a structured noop when addLabelIds is empty', async () => { + const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: [] }); + expect(result.status).toBe('noop'); + expect(result.addedLabelIds).toEqual([]); + expect(mockProvider.addLabel).not.toHaveBeenCalled(); }); - expect(result).toBe('Work item updated: description'); }); - it('adds labels', async () => { - mockProvider.addLabel.mockResolvedValue(undefined); + describe('updated path (provider write)', () => { + it('updates title only and surfaces post-write metadata', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + + const result = await updateWorkItem({ workItemId: 'item1', title: 'New Title' }); + + expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { + title: 'New Title', + description: undefined, + }); + expect(result).toEqual({ + status: 'updated', + id: 'item1', + title: 'Stored title', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: ['title'], + addedLabelIds: [], + }); + }); - const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: ['label1', 'label2'] }); + it('updates description only', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); - expect(mockProvider.addLabel).toHaveBeenCalledTimes(2); - expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label1'); - expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label2'); - expect(result).toBe('Work item updated: 2 label(s)'); - }); + const result = await updateWorkItem({ workItemId: 'item1', description: 'New description' }); - it('updates title and description together', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); + expect(mockProvider.updateWorkItem).toHaveBeenCalledWith('item1', { + title: undefined, + description: 'New description', + }); + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual(['description']); + expect(result.addedLabelIds).toEqual([]); + }); - const result = await updateWorkItem({ workItemId: 'item1', title: 'T', description: 'D' }); + it('adds labels and echoes addedLabelIds without writing title/description', async () => { + mockProvider.addLabel.mockResolvedValue(undefined); + + const result = await updateWorkItem({ + workItemId: 'item1', + addLabelIds: ['label1', 'label2'], + }); + + expect(mockProvider.addLabel).toHaveBeenCalledTimes(2); + expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label1'); + expect(mockProvider.addLabel).toHaveBeenCalledWith('item1', 'label2'); + expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual([]); + expect(result.addedLabelIds).toEqual(['label1', 'label2']); + }); - expect(mockProvider.updateWorkItem).toHaveBeenCalledOnce(); - expect(result).toBe('Work item updated: title, description'); - }); + it('combines title, description, and labels in a single result', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + mockProvider.addLabel.mockResolvedValue(undefined); - it('updates title, description, and labels together', async () => { - mockProvider.updateWorkItem.mockResolvedValue(undefined); - mockProvider.addLabel.mockResolvedValue(undefined); + const result = await updateWorkItem({ + workItemId: 'item1', + title: 'T', + description: 'D', + addLabelIds: ['l1'], + }); - const result = await updateWorkItem({ - workItemId: 'item1', - title: 'T', - description: 'D', - addLabelIds: ['l1'], + expect(result.status).toBe('updated'); + expect(result.changedFields).toEqual(['title', 'description']); + expect(result.addedLabelIds).toEqual(['l1']); }); - - expect(result).toBe('Work item updated: title, description, 1 label(s)'); }); - it('throws an error message on failure', async () => { - mockProvider.updateWorkItem.mockRejectedValue(new Error('API error')); + describe('read-back fallback', () => { + it('synthesises url + timestamp when post-write read-back throws', async () => { + mockProvider.updateWorkItem.mockResolvedValue(undefined); + mockProvider.getWorkItem.mockRejectedValue(new Error('Read-back failed')); + mockProvider.getWorkItemUrl.mockReturnValue('https://fallback.example/item1'); - await expect(updateWorkItem({ workItemId: 'item1', title: 'T' })).rejects.toThrow( - 'Error updating work item: API error', - ); - }); + const result = await updateWorkItem({ workItemId: 'item1', title: 'T' }); - it('does not call updateWorkItem when only labels provided', async () => { - mockProvider.addLabel.mockResolvedValue(undefined); + expect(result.status).toBe('updated'); + expect(result.url).toBe('https://fallback.example/item1'); + expect(typeof result.updatedAt).toBe('string'); + // Title falls back to the caller-supplied title when read-back fails + expect(result.title).toBe('T'); + }); + }); - await updateWorkItem({ workItemId: 'item1', addLabelIds: ['l1'] }); + describe('error propagation', () => { + it('throws on provider updateWorkItem failure (no prose sentinel)', async () => { + mockProvider.updateWorkItem.mockRejectedValue(new Error('API error')); - expect(mockProvider.updateWorkItem).not.toHaveBeenCalled(); - }); + await expect(updateWorkItem({ workItemId: 'item1', title: 'T' })).rejects.toThrow( + 'API error', + ); + }); - it('does not add labels when addLabelIds is empty array', async () => { - const result = await updateWorkItem({ workItemId: 'item1', addLabelIds: [] }); + it('throws on provider addLabel failure', async () => { + mockProvider.addLabel.mockRejectedValue(new Error('Label not found')); - expect(result).toBe('Nothing to update - provide title, description, or labels'); - expect(mockProvider.addLabel).not.toHaveBeenCalled(); + await expect(updateWorkItem({ workItemId: 'item1', addLabelIds: ['l1'] })).rejects.toThrow( + 'Label not found', + ); + }); }); }); diff --git a/tests/unit/gadgets/pm/wrappers.test.ts b/tests/unit/gadgets/pm/wrappers.test.ts new file mode 100644 index 000000000..3be7fc20f --- /dev/null +++ b/tests/unit/gadgets/pm/wrappers.test.ts @@ -0,0 +1,284 @@ +/** + * Focused wrapper tests for the four work-item / comment mutation gadgets. + * + * The cores (`createWorkItem`, `updateWorkItem`, `moveWorkItem`, `postComment`) + * have their own deep tests in `tests/unit/gadgets/pm/core/`. These tests pin + * the wrapper behavior end-to-end — specifically that: + * - Wrappers translate the structured core result to a concise human-readable + * string for the agent tool-result channel (the in-process gadget surface). + * - Wrappers wrap thrown core errors via `formatGadgetError` rather than + * letting them escape. + * - The MoveWorkItem wrapper forwards `expectedSourceState` to the core + * (regression guard for the gap discovered in MNG-1423). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/gadgets/pm/core/createWorkItem.js', () => ({ + createWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/updateWorkItem.js', () => ({ + updateWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/moveWorkItem.js', () => ({ + moveWorkItem: vi.fn(), +})); +vi.mock('../../../../src/gadgets/pm/core/postComment.js', () => ({ + postComment: vi.fn(), +})); + +import { CreateWorkItem } from '../../../../src/gadgets/pm/CreateWorkItem.js'; +import { createWorkItem } from '../../../../src/gadgets/pm/core/createWorkItem.js'; +import { moveWorkItem } from '../../../../src/gadgets/pm/core/moveWorkItem.js'; +import { postComment } from '../../../../src/gadgets/pm/core/postComment.js'; +import { updateWorkItem } from '../../../../src/gadgets/pm/core/updateWorkItem.js'; +import { MoveWorkItem } from '../../../../src/gadgets/pm/MoveWorkItem.js'; +import { PostComment } from '../../../../src/gadgets/pm/PostComment.js'; +import { UpdateWorkItem } from '../../../../src/gadgets/pm/UpdateWorkItem.js'; + +const mockCreateWorkItem = vi.mocked(createWorkItem); +const mockUpdateWorkItem = vi.mocked(updateWorkItem); +const mockMoveWorkItem = vi.mocked(moveWorkItem); +const mockPostComment = vi.mocked(postComment); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// CreateWorkItem wrapper +// --------------------------------------------------------------------------- +describe('CreateWorkItem gadget wrapper', () => { + it('formats the structured result into a concise success string', async () => { + mockCreateWorkItem.mockResolvedValue({ + status: 'created', + id: 'item1', + title: 'New Feature', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new CreateWorkItem(); + const out = await gadget.execute({ + containerId: 'list1', + title: 'New Feature', + description: 'A new feature', + }); + + expect(mockCreateWorkItem).toHaveBeenCalledWith({ + containerId: 'list1', + title: 'New Feature', + description: 'A new feature', + }); + expect(out).toBe( + 'Work item created successfully: "New Feature" [id: item1] - https://trello.com/c/item1', + ); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockCreateWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new CreateWorkItem(); + const out = await gadget.execute({ + containerId: 'list1', + title: 'New Feature', + }); + + expect(out).toBe('Error creating work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// UpdateWorkItem wrapper +// --------------------------------------------------------------------------- +describe('UpdateWorkItem gadget wrapper', () => { + it('renders the noop message when the core returns a noop result', async () => { + mockUpdateWorkItem.mockResolvedValue({ + status: 'noop', + id: 'item1', + title: '', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + }); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ workItemId: 'item1' }); + + expect(out).toBe('Nothing to update - provide title, description, or labels'); + }); + + it('renders the updated fields list for the in-process channel', async () => { + mockUpdateWorkItem.mockResolvedValue({ + status: 'updated', + id: 'item1', + title: 'New', + url: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['l1', 'l2'], + }); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ + workItemId: 'item1', + title: 'New', + description: 'New desc', + addLabelId: ['l1', 'l2'], + }); + + expect(out).toBe('Work item updated: title, description, 2 label(s)'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockUpdateWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new UpdateWorkItem(); + const out = await gadget.execute({ workItemId: 'item1', title: 'T' }); + + expect(out).toBe('Error updating work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// MoveWorkItem wrapper +// --------------------------------------------------------------------------- +describe('MoveWorkItem gadget wrapper', () => { + it('forwards expectedSourceState to the core (regression guard for MNG-1423)', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new MoveWorkItem(); + await gadget.execute({ + workItemId: 'card1', + destination: 'list2', + expectedSourceState: 'Backlog', + }); + + expect(mockMoveWorkItem).toHaveBeenCalledWith({ + workItemId: 'card1', + destination: 'list2', + expectedSourceState: 'Backlog', + }); + }); + + it('renders the success message for a moved outcome', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'moved', + id: 'card1', + url: 'https://trello.com/c/card1', + destination: 'list2', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ workItemId: 'card1', destination: 'list2' }); + + expect(out).toBe('Work item card1 moved to list2 successfully'); + }); + + it('renders the noop message when the work item is already in destination', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'noop', + id: 'MNG-1', + url: 'https://linear.app/team/issue/MNG-1', + destination: 'state-todo', + updatedAt: '2026-03-15T12:00:00.000Z', + previousStatus: 'Todo', + message: "Work item already in destination state 'Todo' — no-op", + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ + workItemId: 'MNG-1', + destination: 'state-todo', + expectedSourceState: 'Backlog', + }); + + expect(out).toBe("Work item already in destination state 'Todo' — no-op"); + }); + + it('renders the aborted message when the guard rejects the move', async () => { + mockMoveWorkItem.mockResolvedValue({ + status: 'aborted', + id: 'MNG-1', + url: 'https://linear.app/team/issue/MNG-1', + destination: 'state-todo', + updatedAt: '2026-03-15T12:00:00.000Z', + previousStatus: 'In Progress', + message: + "Aborted: work item is in 'In Progress', expected 'Backlog' (likely already moved by a parallel agent — skipping to avoid duplicate downstream work)", + }); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ + workItemId: 'MNG-1', + destination: 'state-todo', + expectedSourceState: 'Backlog', + }); + + expect(out).toContain('Aborted'); + expect(out).toContain('In Progress'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockMoveWorkItem.mockRejectedValue(new Error('Boom')); + + const gadget = new MoveWorkItem(); + const out = await gadget.execute({ workItemId: 'card1', destination: 'list2' }); + + expect(out).toBe('Error moving work item: Boom'); + }); +}); + +// --------------------------------------------------------------------------- +// PostComment wrapper +// --------------------------------------------------------------------------- +describe('PostComment gadget wrapper', () => { + it('returns a concise success message for the created path', async () => { + mockPostComment.mockResolvedValue({ + status: 'created', + id: 'comment-1', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Hello' }); + + expect(mockPostComment).toHaveBeenCalledWith('item1', 'Hello'); + expect(out).toBe('Comment posted successfully'); + }); + + it('returns a concise success message for the updated (progress-comment replacement) path', async () => { + mockPostComment.mockResolvedValue({ + status: 'updated', + id: 'comment-42', + workItemId: 'item1', + workItemUrl: 'https://trello.com/c/item1', + updatedAt: '2026-03-15T12:00:00.000Z', + }); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Final summary' }); + + expect(out).toBe('Comment posted successfully'); + }); + + it('returns a formatted error string on thrown core failure', async () => { + mockPostComment.mockRejectedValue(new Error('Boom')); + + const gadget = new PostComment(); + const out = await gadget.execute({ workItemId: 'item1', text: 'Hello' }); + + expect(out).toBe('Error posting comment: Boom'); + }); +}); From d88a9da8685078ca5f213d6b768a224dedcfdc1f Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 1 Jun 2026 16:59:58 +0200 Subject: [PATCH 05/25] feat(gadgets): document mutation output shapes via metadata (MNG-1427) (#1390) Co-authored-by: Cascade Bot --- src/agents/contracts/index.ts | 35 +++ src/backends/shared/nativeToolPrompts.ts | 53 ++++- src/gadgets/github/definitions.ts | 148 ++++++++++++ src/gadgets/pm/definitions.ts | 194 ++++++++++++++++ src/gadgets/shared/cli/examples.ts | 51 ++++- src/gadgets/shared/cliCommandFactory.ts | 5 +- src/gadgets/shared/manifestGenerator.ts | 78 +++++-- src/gadgets/shared/toolDefinition.ts | 78 +++++++ .../backends/shared-nativeToolPrompts.test.ts | 124 ++++++++++ tests/unit/gadgets/github/definitions.test.ts | 82 +++++++ tests/unit/gadgets/pm/definitions.test.ts | 100 +++++++++ tests/unit/gadgets/shared/factories.test.ts | 212 ++++++++++++++++++ 12 files changed, 1135 insertions(+), 25 deletions(-) diff --git a/src/agents/contracts/index.ts b/src/agents/contracts/index.ts index e3ff5af59..6a074ed8b 100644 --- a/src/agents/contracts/index.ts +++ b/src/agents/contracts/index.ts @@ -66,6 +66,35 @@ export interface ToolManifestParameter { fileInputAlternative?: string; } +/** + * MNG-1427: a single field inside the `success.data` JSON payload a CLI + * command returns. Mirrors `OutputShapeField` on {@link ToolDefinition} so + * downstream consumers (prompt renderer, generated help, integration tests) + * can read the same shape without depending on `src/gadgets/`. + */ +export interface ToolManifestOutputShapeField { + /** Field key as it appears in `success.data`. */ + name: string; + /** Type description, e.g. `'string'`, `'number'`, or `'"created" | "updated"'`. */ + type: string; + /** Optional human-readable explanation. */ + description?: string; + /** Whether the field may be absent. Defaults to `false`. */ + optional?: boolean; +} + +/** + * MNG-1427: declarative description of the `success.data` payload returned + * by a CLI command. Surfaced on each {@link ToolManifest} so agents can learn + * which JSON keys to parse without running the tool first. + */ +export interface ToolManifestOutputShape { + /** Optional one-line summary of what `success.data` represents. */ + summary?: string; + /** Field-by-field description of `success.data`. */ + fields: ToolManifestOutputShapeField[]; +} + /** * Describes a CASCADE-specific CLI tool available to the agent. */ @@ -83,6 +112,12 @@ export interface ToolManifest { * index with ad-hoc shapes — new code should cast to `ToolManifestParameter`. */ parameters: Record; + /** + * MNG-1427: optional declarative description of the shape of `success.data` + * returned by the CLI command. Populated for mutation commands so agents + * know which JSON fields to parse without inspecting the response prose. + */ + outputShape?: ToolManifestOutputShape; } /** diff --git a/src/backends/shared/nativeToolPrompts.ts b/src/backends/shared/nativeToolPrompts.ts index 7fec53ee0..2fb923553 100644 --- a/src/backends/shared/nativeToolPrompts.ts +++ b/src/backends/shared/nativeToolPrompts.ts @@ -1,3 +1,7 @@ +import type { + ToolManifestOutputShape, + ToolManifestOutputShapeField, +} from '../../agents/contracts/index.js'; import { formatJsonExample, formatShellScalar } from '../../gadgets/shared/cli/shellValues.js'; import type { ContextInjection, ToolManifest } from '../types.js'; import { buildInlineContextSection, offloadLargeContext } from './contextFiles.js'; @@ -166,6 +170,45 @@ function formatParam(key: string, schema: PromptParamSchema): string { return result; } +/** + * MNG-1427: render a tool manifest's `outputShape` as a concise, parseable + * block beneath the command snippet. The intent is to give native-tool agents + * the JSON field contract for `success.data` without forcing them to run the + * tool first or rely on provider docs. + * + * Rendered shape: + * + * **Output shape** (`success.data`): + * + * - `` (``) — + * - `?` (``) — + * + * The renderer is intentionally lossless: every field in the manifest's + * `outputShape.fields` is emitted in declared order. Empty `fields` arrays + * render as a single placeholder so a definition that opted in but forgot to + * populate fields is loudly visible to the maintainer (and to the agent). + */ +function formatOutputShape(shape: ToolManifestOutputShape): string { + let out = '\n**Output shape** (`success.data`):\n'; + if (shape.summary) { + out += `${shape.summary}\n`; + } + if (shape.fields.length === 0) { + out += '- (shape declared but no fields documented)\n'; + return out; + } + for (const field of shape.fields) { + out += `${formatOutputShapeFieldLine(field)}\n`; + } + return out; +} + +function formatOutputShapeFieldLine(field: ToolManifestOutputShapeField): string { + const nameSuffix = field.optional ? '?' : ''; + const head = `- \`${field.name}${nameSuffix}\` (\`${field.type}\`)`; + return field.description ? `${head} — ${field.description}` : head; +} + /** * Build prompt guidance for CASCADE-specific CLI tools. * Native-tool engines invoke these via shell commands. @@ -191,7 +234,15 @@ export function buildToolGuidance(tools: ToolManifest[]): string { guidance += formatParam(key, schema as PromptParamSchema); } - guidance += '\n```\n\n'; + guidance += '\n```\n'; + + // MNG-1427: render the JSON output contract after the command block so + // agents see `success.data` field-list inline with the command. + if (tool.outputShape) { + guidance += formatOutputShape(tool.outputShape); + } + + guidance += '\n'; } return guidance; diff --git a/src/gadgets/github/definitions.ts b/src/gadgets/github/definitions.ts index d2b8df11a..34317b58d 100644 --- a/src/gadgets/github/definitions.ts +++ b/src/gadgets/github/definitions.ts @@ -153,6 +153,32 @@ If hooks fail, the full output will be shown.`, }, ], }, + outputShape: { + summary: 'CreatePR returns the new (or pre-existing) PR identity.', + fields: [ + { name: 'prNumber', type: 'number', description: 'GitHub pull request number.' }, + { name: 'prUrl', type: 'string', description: 'Pull request HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { + name: 'alreadyExisted', + type: 'boolean', + description: + '`true` when GitHub returned an existing PR for the branch instead of creating one.', + }, + { + name: 'pushOutput', + type: 'string', + optional: true, + description: 'Captured stdout+stderr of `git push` (includes pre-push hook output).', + }, + { + name: 'commitOutput', + type: 'string', + optional: true, + description: 'Captured stdout+stderr of `git commit` (includes pre-commit hook output).', + }, + ], + }, }; export const createPRReviewDef: ToolDefinition = { @@ -253,6 +279,48 @@ export const createPRReviewDef: ToolDefinition = { }, ], }, + outputShape: { + summary: + 'CreatePRReview returns the submitted review identity, event, and inline-comment count.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the review was submitted.', + }, + { name: 'id', type: 'string', description: 'Review ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Review HTML URL.' }, + { + name: 'reviewUrl', + type: 'string', + description: 'Alias of `url`, retained for downstream session-state code.', + }, + { + name: 'event', + type: '"APPROVE" | "REQUEST_CHANGES" | "COMMENT"', + description: 'The review event that was submitted.', + }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'submittedAt', + type: 'string', + description: 'GitHub-supplied `submitted_at` ISO 8601 timestamp.', + }, + { name: 'updatedAt', type: 'string', description: 'ISO 8601 timestamp.' }, + { + name: 'inlineCommentCount', + type: 'number', + description: 'Number of inline review comments accepted by GitHub.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const getPRDetailsDef: ToolDefinition = { @@ -522,6 +590,31 @@ export const postPRCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'PostPRComment returns the posted comment identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the comment was posted.', + }, + { name: 'id', type: 'string', description: 'New comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Comment HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const updatePRCommentDef: ToolDefinition = { @@ -582,6 +675,36 @@ export const updatePRCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'UpdatePRComment returns the updated comment identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the comment body was edited.', + }, + { name: 'id', type: 'string', description: 'Comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Comment HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { + name: 'prNumber', + type: 'number | null', + description: + 'Pull request number when GitHub returns one in the comment html_url; `null` for issue-only comments.', + }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const replyToReviewCommentDef: ToolDefinition = { @@ -648,6 +771,31 @@ export const replyToReviewCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'ReplyToReviewComment returns the threaded reply identity.', + fields: [ + { + name: 'status', + type: '"ok" | "no-op" | "aborted"', + description: 'Generic GitHub mutation status — `"ok"` when the reply was posted.', + }, + { name: 'id', type: 'string', description: 'New reply comment ID (numeric, stringified).' }, + { name: 'url', type: 'string', optional: true, description: 'Reply HTML URL.' }, + { name: 'repoFullName', type: 'string', description: '`owner/repo` of the pull request.' }, + { name: 'prNumber', type: 'number', description: 'Pull request number.' }, + { + name: 'updatedAt', + type: 'string', + description: 'GitHub-supplied `updated_at` ISO 8601 timestamp.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const getCIRunLogsDef: ToolDefinition = { diff --git a/src/gadgets/pm/definitions.ts b/src/gadgets/pm/definitions.ts index ff6017f35..017f25a9d 100644 --- a/src/gadgets/pm/definitions.ts +++ b/src/gadgets/pm/definitions.ts @@ -72,6 +72,25 @@ export const postCommentDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'PostComment returns the new or updated progress comment context.', + fields: [ + { + name: 'status', + type: '"created" | "updated"', + description: + '`"created"` when a new comment was added; `"updated"` when an existing progress comment was edited.', + }, + { name: 'id', type: 'string', description: 'Provider-side comment ID.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp of when the comment was written.', + }, + ], + }, }; export const updateWorkItemDef: ToolDefinition = { @@ -121,6 +140,42 @@ export const updateWorkItemDef: ToolDefinition = { }, ], }, + outputShape: { + summary: + 'UpdateWorkItem returns the affected work item along with the fields that were actually changed.', + fields: [ + { + name: 'status', + type: '"updated" | "noop"', + description: + '`"updated"` if the provider accepted at least one field; `"noop"` when no title/description/labels were supplied.', + }, + { name: 'id', type: 'string', description: 'Work item ID.' }, + { name: 'title', type: 'string', description: 'Current title read back from the provider.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp of the update (synthesised on `"noop"`).', + }, + { + name: 'changedFields', + type: '("title" | "description")[]', + description: 'Fields that were sent to the provider; empty array on `"noop"`.', + }, + { + name: 'addedLabelIds', + type: 'string[]', + description: 'Label IDs successfully attached; empty array when no labels were supplied.', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note (used on `"noop"`).', + }, + ], + }, }; export const createWorkItemDef: ToolDefinition = { @@ -166,6 +221,36 @@ export const createWorkItemDef: ToolDefinition = { }, ], }, + outputShape: { + summary: 'CreateWorkItem returns the newly-created work item.', + fields: [ + { + name: 'status', + type: '"created"', + description: 'Always `"created"` when the provider accepted the new work item.', + }, + { name: 'id', type: 'string', description: 'New work item ID.' }, + { name: 'title', type: 'string', description: 'Persisted title.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 creation timestamp from the provider.', + }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Human-readable workflow state when the provider surfaces one on create.', + }, + { + name: 'workflowStatusId', + type: 'string', + optional: true, + description: 'Provider-native workflow state ID (Trello list ID, Linear state UUID, etc.).', + }, + ], + }, }; export const reportFrictionDef: ToolDefinition = { @@ -297,6 +382,48 @@ export const moveWorkItemDef: ToolDefinition = { 'Backlog-manager moving a freshly-picked item to TODO — guarded so a parallel run that already moved it cannot duplicate the move.', }, ], + outputShape: { + summary: 'MoveWorkItem reports whether the provider accepted the move, skipped it, or aborted.', + fields: [ + { + name: 'status', + type: '"moved" | "noop" | "aborted"', + description: + '`"moved"` on a successful move; `"noop"` when the work item was already in `destination`; `"aborted"` when `expectedSourceState` did not match the current state.', + }, + { name: 'id', type: 'string', description: 'Work item ID.' }, + { name: 'url', type: 'string', description: 'Work item URL.' }, + { + name: 'destination', + type: 'string', + description: 'The destination passed to the provider (list ID or status name).', + }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp; synthesised for `"noop"` and `"aborted"` outcomes.', + }, + { + name: 'previousStatus', + type: 'string', + optional: true, + description: + 'Current human-readable workflow status read back from the provider on the guarded path.', + }, + { + name: 'previousStatusId', + type: 'string', + optional: true, + description: 'Native ID of the previous status (Trello list ID, Linear state UUID, etc.).', + }, + { + name: 'message', + type: 'string', + optional: true, + description: 'Optional human-readable note explaining the outcome.', + }, + ], + }, }; export const addChecklistDef: ToolDefinition = { @@ -344,6 +471,40 @@ export const addChecklistDef: ToolDefinition = { comment: 'Add implementation steps with descriptions to a JIRA issue', }, ], + outputShape: { + summary: 'AddChecklist returns the freshly-created checklist and its item identities.', + fields: [ + { + name: 'status', + type: '"created"', + description: 'Always `"created"` when the provider accepted the checklist write.', + }, + { name: 'checklistId', type: 'string', description: 'New checklist ID.' }, + { + name: 'checklistName', + type: 'string', + description: 'Persisted checklist name (matches the `checklistName` argument).', + }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + { + name: 'itemCount', + type: 'number', + description: 'Count of checklist items written by the provider.', + }, + { + name: 'itemIds', + type: 'string[]', + description: + "Per-item IDs surfaced by the provider. Best-effort: inline-description providers (Linear, JIRA) return deterministic hashed IDs; Trello's native fallback may return an empty array.", + }, + ], + }, }; export const pmUpdateChecklistItemDef: ToolDefinition = { @@ -379,6 +540,25 @@ export const pmUpdateChecklistItemDef: ToolDefinition = { comment: 'Mark an item as complete', }, ], + outputShape: { + summary: 'PMUpdateChecklistItem confirms the resulting checklist item state.', + fields: [ + { name: 'status', type: '"updated"', description: 'Always `"updated"` on success.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { name: 'checkItemId', type: 'string', description: 'The affected checklist item ID.' }, + { + name: 'complete', + type: 'boolean', + description: 'Resulting state — `true` for `"complete"`, `false` for `"incomplete"`.', + }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + ], + }, }; export const pmDeleteChecklistItemDef: ToolDefinition = { @@ -407,4 +587,18 @@ export const pmDeleteChecklistItemDef: ToolDefinition = { comment: 'Delete a descoped subtask from a JIRA issue', }, ], + outputShape: { + summary: 'PMDeleteChecklistItem confirms the removed checklist item.', + fields: [ + { name: 'status', type: '"deleted"', description: 'Always `"deleted"` on success.' }, + { name: 'workItemId', type: 'string', description: 'Parent work item ID.' }, + { name: 'workItemUrl', type: 'string', description: 'Parent work item URL.' }, + { name: 'checkItemId', type: 'string', description: 'The deleted checklist item ID.' }, + { + name: 'updatedAt', + type: 'string', + description: 'ISO 8601 timestamp from the provider read-back.', + }, + ], + }, }; diff --git a/src/gadgets/shared/cli/examples.ts b/src/gadgets/shared/cli/examples.ts index 8bfcc8c74..c3eb223a3 100644 --- a/src/gadgets/shared/cli/examples.ts +++ b/src/gadgets/shared/cli/examples.ts @@ -1,4 +1,10 @@ -import type { ParameterDefinition, ToolDefinition, ToolExample } from '../toolDefinition.js'; +import type { + OutputShape, + OutputShapeField, + ParameterDefinition, + ToolDefinition, + ToolExample, +} from '../toolDefinition.js'; import { formatJsonExample, formatShellScalar, shellQuote } from './shellValues.js'; /** @@ -65,3 +71,46 @@ export function expectedShapeFor(paramDef: ParameterDefinition, example?: unknow } return paramDef.describe; } + +/** + * MNG-1427: render an `OutputShape` as a plain-text block suitable for + * appending to an oclif `static description`. The block surfaces in + * `cascade-tools --help` output beneath the regular + * description so agents reading help text see the same `success.data` + * contract as in the system-prompt guidance. + * + * The renderer is intentionally minimal — no markdown emphasis, no surrounding + * blank lines — because oclif word-wraps and indents the description block + * automatically. + */ +export function renderOutputShapeForHelp(shape: OutputShape): string { + let block = 'Output shape (success.data):'; + if (shape.summary) { + block += `\n${shape.summary}`; + } + if (shape.fields.length === 0) { + block += '\n- (shape declared but no fields documented)'; + return block; + } + for (const field of shape.fields) { + block += `\n${formatOutputShapeFieldForHelp(field)}`; + } + return block; +} + +function formatOutputShapeFieldForHelp(field: OutputShapeField): string { + const nameSuffix = field.optional ? '?' : ''; + const head = `- ${field.name}${nameSuffix} (${field.type})`; + return field.description ? `${head} — ${field.description}` : head; +} + +/** + * MNG-1427: assemble the oclif `static description` string by appending the + * rendered output-shape block (when declared) to the tool's base description. + * Used by `createCLICommand` so `--help` output picks up the contract without + * touching the prompt path. + */ +export function buildOclifDescription(def: ToolDefinition): string { + if (!def.outputShape) return def.description; + return `${def.description}\n\n${renderOutputShapeForHelp(def.outputShape)}`; +} diff --git a/src/gadgets/shared/cliCommandFactory.ts b/src/gadgets/shared/cliCommandFactory.ts index cf349963c..4ab471cfc 100644 --- a/src/gadgets/shared/cliCommandFactory.ts +++ b/src/gadgets/shared/cliCommandFactory.ts @@ -13,7 +13,7 @@ import { CredentialScopedCommand } from '../../cli/base.js'; import { massageBooleanFlagValues } from './cli/booleanArgv.js'; import { deriveCLICommand } from './cli/commandNames.js'; import { buildSink } from './cli/errorSink.js'; -import { buildOclifExamples } from './cli/examples.js'; +import { buildOclifDescription, buildOclifExamples } from './cli/examples.js'; import { buildFlagsRecord, collectBooleanFlagNames, collectCandidateFlags } from './cli/flags.js'; import { rejectMultipleStdinConsumers, @@ -81,10 +81,11 @@ export function createCLICommand( const commandPrefix = deriveCLICommand(def.name); const staticExamples = buildOclifExamples(def, commandPrefix); + const staticDescription = buildOclifDescription(def); const booleanFlagNames = collectBooleanFlagNames(def); class FactoryCommand extends CredentialScopedCommand { - static override description = def.description; + static override description = staticDescription; static override flags = flagsRecord; static override examples = staticExamples; diff --git a/src/gadgets/shared/manifestGenerator.ts b/src/gadgets/shared/manifestGenerator.ts index 4f461000a..ef433b802 100644 --- a/src/gadgets/shared/manifestGenerator.ts +++ b/src/gadgets/shared/manifestGenerator.ts @@ -12,10 +12,14 @@ * - The `cliCommand` is derived from the definition name (kebab-cased) */ -import type { ToolManifest, ToolManifestParameter } from '../../agents/contracts/index.js'; +import type { + ToolManifest, + ToolManifestOutputShape, + ToolManifestParameter, +} from '../../agents/contracts/index.js'; import { deriveCLICommand } from './cli/commandNames.js'; import { findExampleForParam } from './cli/examples.js'; -import type { ParameterDefinition, ToolDefinition } from './toolDefinition.js'; +import type { OutputShape, ParameterDefinition, ToolDefinition } from './toolDefinition.js'; // --------------------------------------------------------------------------- // Helpers @@ -96,6 +100,32 @@ export function generateToolManifest( def: ToolDefinition, cliCommandOverride?: string, ): ToolManifest { + const parameters = buildManifestParameters(def); + const cliCommand = deriveCLICommand(def.name, cliCommandOverride); + + // MNG-1427: thread the optional output-shape descriptor unchanged into the + // manifest so downstream consumers (prompt renderer, generated help, + // integration tests) see the same shape declared on the definition. + const outputShape = buildManifestOutputShape(def.outputShape); + + return { + name: def.name, + description: def.description, + cliCommand, + parameters, + ...(outputShape ? { outputShape } : {}), + }; +} + +/** + * Build the `parameters` map for a manifest — including direct params from the + * definition AND file-input alternative flags. Extracted from + * {@link generateToolManifest} so the top-level function stays under the + * cognitive-complexity budget; the rendering rules (gadgetOnly exclusion, + * file-input cross-references, examples) are unchanged from the original + * inline code. + */ +function buildManifestParameters(def: ToolDefinition): Record { const parameters: Record = {}; // MNG-1059: build a quick lookup of paramName → fileFlag so the manifest @@ -127,28 +157,34 @@ export function generateToolManifest( } // Add file-input alternative flags to the manifest - if (def.cli?.fileInputAlternatives) { - for (const alt of def.cli.fileInputAlternatives) { - const description = - alt.description ?? - `Path to file with ${alt.paramName} (prefer over --${alt.paramName} for long content)`; - parameters[alt.fileFlag] = { - type: 'string', - description, - // MNG-1059: cross-reference back to the direct text param so the - // prompt renderer can group `--body` and `--body-file` semantically. - fileInputFor: alt.paramName, - // File flags are always optional (they are alternatives to the direct param) - }; - } + for (const alt of def.cli?.fileInputAlternatives ?? []) { + const description = + alt.description ?? + `Path to file with ${alt.paramName} (prefer over --${alt.paramName} for long content)`; + parameters[alt.fileFlag] = { + type: 'string', + description, + // MNG-1059: cross-reference back to the direct text param so the + // prompt renderer can group `--body` and `--body-file` semantically. + fileInputFor: alt.paramName, + // File flags are always optional (they are alternatives to the direct param) + }; } - const cliCommand = deriveCLICommand(def.name, cliCommandOverride); + return parameters; +} +function buildManifestOutputShape( + outputShape: OutputShape | undefined, +): ToolManifestOutputShape | undefined { + if (!outputShape) return undefined; return { - name: def.name, - description: def.description, - cliCommand, - parameters, + ...(outputShape.summary ? { summary: outputShape.summary } : {}), + fields: outputShape.fields.map((f) => ({ + name: f.name, + type: f.type, + ...(f.description ? { description: f.description } : {}), + ...(f.optional ? { optional: true } : {}), + })), }; } diff --git a/src/gadgets/shared/toolDefinition.ts b/src/gadgets/shared/toolDefinition.ts index 68541e877..bcffe81ae 100644 --- a/src/gadgets/shared/toolDefinition.ts +++ b/src/gadgets/shared/toolDefinition.ts @@ -246,6 +246,71 @@ export interface ToolExample { comment?: string; } +// --------------------------------------------------------------------------- +// Output shape (MNG-1427) +// --------------------------------------------------------------------------- + +/** + * A single field inside the `success.data` payload returned by the CLI. + * + * Output-shape fields are declarative metadata — the same descriptor flows + * unchanged through the generated manifest, the native-tool prompt guidance, + * and the generated `cascade-tools --help` output. Agents + * use them to learn which JSON keys to parse without having to run the tool + * first or read provider docs. + * + * @example + * { name: 'id', type: 'string', description: 'The comment ID' } + * { name: 'status', type: '"created" | "updated"', description: 'Whether a new comment was created or an existing one was updated' } + * { name: 'workflowStatus', type: 'string', optional: true, description: 'Human-readable workflow state' } + */ +export interface OutputShapeField { + /** Field key as it appears in `success.data` (camelCase by convention). */ + name: string; + /** + * Type description rendered into prompts/help verbatim. Use simple JSON-ish + * notation: `'string'`, `'number'`, `'boolean'`, `'string[]'`, or a literal + * union like `'"created" | "updated"'`. + */ + type: string; + /** Optional one-line explanation of the field. */ + description?: string; + /** Whether the field may be absent from `success.data`. Defaults to `false`. */ + optional?: boolean; +} + +/** + * Declarative description of the shape of `success.data` returned by a tool. + * + * The metadata is rendered (not interpreted) by the prompt and help layers, + * so the same descriptor populates: + * - Native-tool prompt guidance (a concise field-list rendered after the + * command block in the system prompt). + * - `cascade-tools --help` output (an "OUTPUT SHAPE" + * section appended to the description). + * - Generated manifests (so downstream consumers can re-render or assert on + * the contract). + * + * Omit on read-only commands whose response shapes are already self-evident + * from their underlying read API. Populate on mutation commands so agents can + * confirm affected IDs / URLs / statuses without parsing free-form prose. + */ +export interface OutputShape { + /** + * Optional one-line summary of what `success.data` represents. Rendered as + * a single sentence above the field list. + * + * @example 'A PostComment success returns the new (or updated) progress comment context.' + */ + summary?: string; + /** + * Field-by-field description of `success.data`. At least one entry is + * expected; an empty array is treated as "shape declared but unspecified" + * and rendered with a note. + */ + fields: OutputShapeField[]; +} + // --------------------------------------------------------------------------- // Hook types // --------------------------------------------------------------------------- @@ -383,4 +448,17 @@ export interface ToolDefinition { * Use for gadgets that signal session termination (e.g., Finish). */ exclusive?: boolean; + + /** + * MNG-1427: declarative description of `success.data` shape returned by + * this tool. When set, the field is propagated unchanged into the generated + * manifest, rendered into native-tool prompt guidance, and surfaced in + * `cascade-tools --help` output so agents know which + * JSON fields to parse. + * + * Populate on mutation commands (PostComment, CreatePR, etc.). Read-only + * commands typically omit this field; their response shapes are already + * conveyed by the description and the underlying read API. + */ + outputShape?: OutputShape; } diff --git a/tests/unit/backends/shared-nativeToolPrompts.test.ts b/tests/unit/backends/shared-nativeToolPrompts.test.ts index 6792d71f9..3cf235d91 100644 --- a/tests/unit/backends/shared-nativeToolPrompts.test.ts +++ b/tests/unit/backends/shared-nativeToolPrompts.test.ts @@ -553,6 +553,130 @@ describe('buildToolGuidance', () => { }); }); +// ------------------------------------------------------------------------- +// MNG-1427: output-shape rendering after the command block +// ------------------------------------------------------------------------- +describe('outputShape — render after the command block (MNG-1427)', () => { + it('renders an Output shape section when the manifest declares one', () => { + const result = buildToolGuidance([ + makeManifest({ + name: 'PostComment', + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }), + ]); + + expect(result).toContain('**Output shape** (`success.data`):'); + expect(result).toContain('PostComment returns the new comment context.'); + expect(result).toContain('- `status` (`"created" | "updated"`) — Outcome.'); + expect(result).toContain('- `id` (`string`) — Comment ID.'); + }); + + it('renders the output shape after the closing code fence (parseable order)', () => { + const result = buildToolGuidance([ + makeManifest({ + name: 'CreateWorkItem', + outputShape: { + fields: [{ name: 'id', type: 'string', description: 'New work item ID.' }], + }, + }), + ]); + + const fenceIndex = result.indexOf('\n```\n'); + const outputIndex = result.indexOf('**Output shape**'); + expect(fenceIndex).toBeGreaterThan(-1); + expect(outputIndex).toBeGreaterThan(fenceIndex); + }); + + it('marks optional fields with a trailing `?`', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [ + { name: 'id', type: 'string' }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Provider-dependent.', + }, + ], + }, + }), + ]); + + expect(result).toContain('- `id` (`string`)'); + expect(result).toContain('- `workflowStatus?` (`string`) — Provider-dependent.'); + }); + + it('does not render Output shape section when no shape is declared', () => { + const result = buildToolGuidance([makeManifest({ name: 'ReadOnlyTool' })]); + expect(result).not.toContain('**Output shape**'); + }); + + it('renders a placeholder line when fields array is empty', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { fields: [] }, + }), + ]); + + expect(result).toContain('**Output shape**'); + expect(result).toContain('- (shape declared but no fields documented)'); + }); + + it('omits the summary line when none is declared', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }), + ]); + + expect(result).toContain('**Output shape**'); + expect(result).toContain('- `id` (`string`)'); + }); + + it('renders fields without descriptions as bare name/type entries', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [{ name: 'workItemId', type: 'string' }], + }, + }), + ]); + + expect(result).toContain('- `workItemId` (`string`)'); + expect(result).not.toContain('- `workItemId` (`string`) —'); + }); + + it('preserves declared field order', () => { + const result = buildToolGuidance([ + makeManifest({ + outputShape: { + fields: [ + { name: 'first', type: 'string' }, + { name: 'second', type: 'string' }, + { name: 'third', type: 'string' }, + ], + }, + }), + ]); + + const firstIdx = result.indexOf('- `first`'); + const secondIdx = result.indexOf('- `second`'); + const thirdIdx = result.indexOf('- `third`'); + expect(firstIdx).toBeLessThan(secondIdx); + expect(secondIdx).toBeLessThan(thirdIdx); + }); +}); + // ───────── buildSystemPrompt ───────── describe('buildSystemPrompt', () => { it('prepends native tool execution rules', () => { diff --git a/tests/unit/gadgets/github/definitions.test.ts b/tests/unit/gadgets/github/definitions.test.ts index 11f55423b..2fd37929d 100644 --- a/tests/unit/gadgets/github/definitions.test.ts +++ b/tests/unit/gadgets/github/definitions.test.ts @@ -335,6 +335,88 @@ describe('getPRDiffDef', () => { }); }); +// --------------------------------------------------------------------------- +// MNG-1427: GitHub mutation output-shape coverage +// --------------------------------------------------------------------------- + +describe('GitHub mutation output shapes (MNG-1427)', () => { + const MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE: ToolDefinition[] = [ + createPRDef, + createPRReviewDef, + postPRCommentDef, + updatePRCommentDef, + replyToReviewCommentDef, + ]; + + const READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE: ToolDefinition[] = [ + getPRDetailsDef, + getPRDiffDef, + getPRChecksDef, + getPRCommentsDef, + getCIRunLogsDef, + ]; + + it('every SCM mutation definition declares an outputShape with at least one field', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must declare outputShape`).toBeDefined(); + expect( + def.outputShape?.fields.length, + `${def.name} outputShape must list at least one field`, + ).toBeGreaterThan(0); + } + }); + + it('every output-shape field has a non-empty name and type', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + for (const field of def.outputShape?.fields ?? []) { + expect(typeof field.name).toBe('string'); + expect(field.name.length).toBeGreaterThan(0); + expect(typeof field.type).toBe('string'); + expect(field.type.length).toBeGreaterThan(0); + } + } + }); + + it('read-only SCM definitions do not declare an outputShape', () => { + for (const def of READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must NOT declare outputShape`).toBeUndefined(); + } + }); + + it('CreatePR output shape mirrors the CreatePRResult contract', () => { + const names = createPRDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('prNumber'); + expect(names).toContain('prUrl'); + expect(names).toContain('repoFullName'); + expect(names).toContain('alreadyExisted'); + }); + + it('CreatePR pushOutput / commitOutput are optional', () => { + const fieldsByName = new Map((createPRDef.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect(fieldsByName.get('pushOutput')?.optional).toBe(true); + expect(fieldsByName.get('commitOutput')?.optional).toBe(true); + }); + + it('CreatePRReview output shape includes reviewUrl + inlineCommentCount', () => { + const names = createPRReviewDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('reviewUrl'); + expect(names).toContain('inlineCommentCount'); + expect(names).toContain('event'); + }); + + it('CreatePRReview event type covers the APPROVE / REQUEST_CHANGES / COMMENT union', () => { + const event = createPRReviewDef.outputShape?.fields.find((f) => f.name === 'event'); + expect(event?.type).toContain('APPROVE'); + expect(event?.type).toContain('REQUEST_CHANGES'); + expect(event?.type).toContain('COMMENT'); + }); + + it('UpdatePRComment prNumber is `number | null`', () => { + const prNumber = updatePRCommentDef.outputShape?.fields.find((f) => f.name === 'prNumber'); + expect(prNumber?.type).toBe('number | null'); + }); +}); + // --------------------------------------------------------------------------- // Spec 014 plan 2: createPRReviewDef declarative opt-in // --------------------------------------------------------------------------- diff --git a/tests/unit/gadgets/pm/definitions.test.ts b/tests/unit/gadgets/pm/definitions.test.ts index 4971a257c..862b0b962 100644 --- a/tests/unit/gadgets/pm/definitions.test.ts +++ b/tests/unit/gadgets/pm/definitions.test.ts @@ -312,4 +312,104 @@ describe('PM gadget definitions', () => { expect(pmDeleteChecklistItemDef.parameters.checkItemId?.required).toBe(true); }); }); + + // ─── Output shape coverage (MNG-1427) ────────────────────────────────────── + describe('output shape coverage (MNG-1427)', () => { + const MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE: ToolDefinition[] = [ + postCommentDef, + updateWorkItemDef, + createWorkItemDef, + moveWorkItemDef, + addChecklistDef, + pmUpdateChecklistItemDef, + pmDeleteChecklistItemDef, + ]; + + const READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE: ToolDefinition[] = [ + readWorkItemDef, + listWorkItemsDef, + ]; + + it('every PM mutation definition declares an outputShape with at least one field', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must declare outputShape`).toBeDefined(); + expect( + def.outputShape?.fields.length, + `${def.name} outputShape must list at least one field`, + ).toBeGreaterThan(0); + } + }); + + it('every output-shape field has a non-empty name and type', () => { + for (const def of MUTATION_DEFS_WITH_REQUIRED_OUTPUT_SHAPE) { + for (const field of def.outputShape?.fields ?? []) { + expect(typeof field.name).toBe('string'); + expect(field.name.length).toBeGreaterThan(0); + expect(typeof field.type).toBe('string'); + expect(field.type.length).toBeGreaterThan(0); + } + } + }); + + it('read-only definitions do not declare an outputShape', () => { + for (const def of READ_ONLY_DEFS_WITHOUT_OUTPUT_SHAPE) { + expect(def.outputShape, `${def.name} must NOT declare outputShape`).toBeUndefined(); + } + }); + + it('PostComment output shape mirrors the CommentPostedResult contract', () => { + const names = postCommentDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('status'); + expect(names).toContain('id'); + expect(names).toContain('workItemId'); + expect(names).toContain('workItemUrl'); + expect(names).toContain('updatedAt'); + }); + + it('UpdateWorkItem output shape mirrors the WorkItemUpdatedResult contract', () => { + const fieldsByName = new Map( + (updateWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + expect(fieldsByName.get('status')?.type).toBe('"updated" | "noop"'); + expect(fieldsByName.get('changedFields')?.type).toBe('("title" | "description")[]'); + expect(fieldsByName.get('addedLabelIds')?.type).toBe('string[]'); + expect(fieldsByName.get('message')?.optional).toBe(true); + }); + + it('MoveWorkItem output shape encodes the moved/noop/aborted union', () => { + const status = moveWorkItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"moved" | "noop" | "aborted"'); + const previousStatus = moveWorkItemDef.outputShape?.fields.find( + (f) => f.name === 'previousStatus', + ); + expect(previousStatus?.optional).toBe(true); + }); + + it('CreateWorkItem output shape includes workflowStatus / workflowStatusId as optional', () => { + const fieldsByName = new Map( + (createWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + expect(fieldsByName.get('workflowStatus')?.optional).toBe(true); + expect(fieldsByName.get('workflowStatusId')?.optional).toBe(true); + }); + + it('AddChecklist output shape carries checklistId + itemIds', () => { + const names = addChecklistDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('checklistId'); + expect(names).toContain('itemIds'); + expect(names).toContain('itemCount'); + }); + + it('PMUpdateChecklistItem output shape surfaces the resulting boolean state', () => { + const complete = pmUpdateChecklistItemDef.outputShape?.fields.find( + (f) => f.name === 'complete', + ); + expect(complete?.type).toBe('boolean'); + }); + + it('PMDeleteChecklistItem output shape uses status="deleted"', () => { + const status = pmDeleteChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"deleted"'); + }); + }); }); diff --git a/tests/unit/gadgets/shared/factories.test.ts b/tests/unit/gadgets/shared/factories.test.ts index 4da31dbce..2d8b7cef3 100644 --- a/tests/unit/gadgets/shared/factories.test.ts +++ b/tests/unit/gadgets/shared/factories.test.ts @@ -1583,6 +1583,218 @@ describe('generateToolManifest — widened fields (spec 014)', () => { }); }); +// --------------------------------------------------------------------------- +// MNG-1427: createCLICommand surfaces outputShape in oclif description (`--help`) +// --------------------------------------------------------------------------- + +describe('createCLICommand — outputShape in --help description (MNG-1427)', () => { + it('appends "Output shape (success.data):" to the oclif description when declared', () => { + const def: ToolDefinition = { + name: 'PostComment', + description: 'Post a comment to a work item.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + text: { type: 'string', describe: 'Comment text', required: true }, + }, + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + + expect(CommandClass.description).toContain('Post a comment to a work item.'); + expect(CommandClass.description).toContain('Output shape (success.data):'); + expect(CommandClass.description).toContain('PostComment returns the new comment context.'); + expect(CommandClass.description).toContain('- status ("created" | "updated") — Outcome.'); + expect(CommandClass.description).toContain('- id (string) — Comment ID.'); + }); + + it('keeps the oclif description unchanged when no outputShape is declared', () => { + const def: ToolDefinition = { + name: 'ReadOnlyTool', + description: 'Read-only operation, no mutation result.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + + expect(CommandClass.description).toBe('Read-only operation, no mutation result.'); + expect(CommandClass.description).not.toContain('Output shape'); + }); + + it('marks optional fields with a trailing `?` in the help block', () => { + const def: ToolDefinition = { + name: 'CreateWorkItem', + description: 'Create a new work item.', + parameters: { + containerId: { type: 'string', describe: 'Container', required: true }, + title: { type: 'string', describe: 'Title', required: true }, + }, + outputShape: { + fields: [ + { name: 'id', type: 'string', description: 'Required.' }, + { + name: 'workflowStatus', + type: 'string', + optional: true, + description: 'Provider-dependent.', + }, + ], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + expect(CommandClass.description).toContain('- id (string) — Required.'); + expect(CommandClass.description).toContain('- workflowStatus? (string) — Provider-dependent.'); + }); + + it('omits markdown emphasis to keep oclif word-wrap clean', () => { + const def: ToolDefinition = { + name: 'Plain', + description: 'A plain mutation.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + // The help renderer is intentionally plain text — no `**` emphasis, no + // backtick-wrapped field names. Help formatting is provided by oclif. + expect(CommandClass.description).not.toContain('**Output shape**'); + expect(CommandClass.description).not.toContain('`id`'); + }); + + it('renders the empty-fields placeholder when fields: [] is declared', () => { + const def: ToolDefinition = { + name: 'EmptyShape', + description: 'Mutation with no documented fields.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { fields: [] }, + }; + + const coreFn: CLICoreFn = async () => 'ok'; + const CommandClass = createCLICommand(def, coreFn); + expect(CommandClass.description).toContain('Output shape (success.data):'); + expect(CommandClass.description).toContain('- (shape declared but no fields documented)'); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1427: generateToolManifest threads outputShape into the manifest +// --------------------------------------------------------------------------- + +describe('generateToolManifest — outputShape propagation (MNG-1427)', () => { + it('threads outputShape onto the manifest when the definition declares one', () => { + const def: ToolDefinition = { + name: 'PostComment', + description: 'Post a comment.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + text: { type: 'string', describe: 'Comment text', required: true }, + }, + outputShape: { + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toEqual({ + summary: 'PostComment returns the new comment context.', + fields: [ + { name: 'status', type: '"created" | "updated"', description: 'Outcome.' }, + { name: 'id', type: 'string', description: 'Comment ID.' }, + ], + }); + }); + + it('omits manifest.outputShape when the definition has no outputShape', () => { + const def: ToolDefinition = { + name: 'ReadOnlyTool', + description: 'A read-only tool.', + parameters: { + workItemId: { type: 'string', describe: 'Work item ID', required: true }, + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toBeUndefined(); + }); + + it('omits empty summary keys while preserving the rest of the shape', () => { + const def: ToolDefinition = { + name: 'SilentOutcome', + description: 'Mutation without a summary.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [{ name: 'id', type: 'string' }], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape).toEqual({ fields: [{ name: 'id', type: 'string' }] }); + expect(manifest.outputShape).not.toHaveProperty('summary'); + }); + + it('preserves the optional flag on output-shape fields end-to-end', () => { + const def: ToolDefinition = { + name: 'WithOptionalField', + description: 'Definition with an optional output field.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { + fields: [ + { name: 'id', type: 'string', description: 'always present' }, + { name: 'message', type: 'string', optional: true, description: 'only on error' }, + ], + }, + }; + + const manifest = generateToolManifest(def); + expect(manifest.outputShape?.fields[0]?.optional).toBeUndefined(); + expect(manifest.outputShape?.fields[1]?.optional).toBe(true); + }); + + it('rejects mutation of the source definition (manifest is a fresh clone)', () => { + const fields = [{ name: 'id', type: 'string' }]; + const def: ToolDefinition = { + name: 'Mutated', + description: 'Mutation definition.', + parameters: { + value: { type: 'string', describe: 'value', required: true }, + }, + outputShape: { fields }, + }; + + const manifest = generateToolManifest(def); + // Mutating the manifest output must not propagate back to the source. + manifest.outputShape?.fields.push({ name: 'extra', type: 'string' }); + expect(fields).toHaveLength(1); + }); +}); + // --------------------------------------------------------------------------- // MNG-1059: manifest threads fileInputFor / fileInputAlternative cross-refs // --------------------------------------------------------------------------- From 0e9b30e91f194fe326a7ef8255d12f736e501287 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 2 Jun 2026 12:59:24 +0200 Subject: [PATCH 06/25] test(gadgets): pin structured-output regression coverage (MNG-1428) (#1391) * test(gadgets): pin structured-output regression coverage (MNG-1428) * fix: address feedback * fix: address feedback --------- Co-authored-by: Cascade Bot --- docs/architecture/07-gadgets.md | 31 ++ src/gadgets/README.md | 76 +++ tests/unit/cli/pm/pm-commands.test.ts | 499 ++++++++++++++++++ tests/unit/cli/scm/scm-commands.test.ts | 249 +++++++++ tests/unit/gadgets/github/definitions.test.ts | 67 +++ tests/unit/gadgets/pm/definitions.test.ts | 99 ++++ 6 files changed, 1021 insertions(+) diff --git a/docs/architecture/07-gadgets.md b/docs/architecture/07-gadgets.md index d9e5be336..244ee90a5 100644 --- a/docs/architecture/07-gadgets.md +++ b/docs/architecture/07-gadgets.md @@ -148,6 +148,37 @@ New domain commands should not add branches in these helpers. They declare behav Core functions passed to `createCLICommand()` own domain work only. On fatal runtime/API/provider failures they throw, and the shared factory converts that exception into the structured `{"success":false,"error":{"type":"runtime","message":"..."}}` stdout envelope plus exit code 1. A returned value is always serialized as successful `data`, so gadgets must not return sentinel error strings such as `Error reading work item: ...` for fatal failures. Non-fatal command states that are part of the contract, such as guarded PM move no-ops or friction retry queueing, remain successful returns. +### Mutation result contract (MNG-1422 → MNG-1428) + +Every PM mutation core and the SCM PR comment/reply/update/review mutation cores covered by MNG-1428 return structured objects, never prose. The CLI factory serialises those objects verbatim into `{"success":true,"data":{...}}`, so consumers (downstream agents, sidecars, review/respond workflows) can read structured keys directly. + +These targeted mutations surface these contract fields on `success.data`: + +| Field | Meaning | +|---|---| +| `status` | The MUTATION OUTCOME — `"created"`/`"updated"`/`"moved"`/`"noop"`/`"aborted"`/`"deleted"` (PM) or `"ok"`/`"no-op"`/`"aborted"` (SCM). Branch on this, not on prose. | +| `updatedAt` | ISO 8601 timestamp string. It is always present and parseable; the source varies by mutation and fallback path. | + +Identity and URL fields are mutation-specific. Work-item and comment mutations expose `id` plus their canonical resource URL (`url` or, for PM comments, `workItemUrl`). `AddChecklist` exposes `checklistId` and `workItemUrl`, plus `itemIds` / `itemCount`; `PMUpdateChecklistItem` and `PMDeleteChecklistItem` expose `checkItemId` and `workItemUrl`. Targeted SCM PR comment/reply/update/review mutations additionally surface `id`, `url`, `repoFullName`, and `prNumber` (the latter widens to `number | null` for `UpdatePRComment` when GitHub returns an issue-only comment URL). `CreatePRReview` extends with `reviewUrl`, `event`, `submittedAt`, and `inlineCommentCount`. `CreatePR` is also a structured SCM mutation, but it is outside MNG-1428's shared `status` / `updatedAt` / `id` / `url` contract and keeps its existing shape: `prNumber`, `prUrl`, `repoFullName`, and `alreadyExisted` plus optional commit/push details. The full per-mutation shapes live on the matching `ToolDefinition.outputShape` blocks under `src/gadgets/{pm,github}/definitions.ts`. + +**`status` vs `workflowStatus` naming.** `status` is reserved for the mutation outcome alone. The PM provider's workflow state — Linear's "In Progress", a Trello list name, a JIRA status — lives on its own keys: `workflowStatus` (human-readable) and `workflowStatusId` (native ID). `MoveWorkItem` also exposes `previousStatus` / `previousStatusId` for the work item's pre-move workflow state on the guarded path. Mixing the two surfaces once cost ~2½ minutes of agent time (prod run `5d993b04`); the dual-key naming is now load-bearing. + +**Fatal failures throw.** Cores propagate runtime/API/provider errors as exceptions; the CLI factory emits the spec-014 runtime envelope (`{"success":false,"error":{"type":"runtime","message":"..."}}`). Do NOT return sentinel strings like `"Error creating work item: ..."` — the CLI cannot distinguish a string return from a successful `data` payload, so the envelope would say `success: true` and the agent would silently mis-act. + +**Timestamp fallback semantics.** The stable contract is that `updatedAt` is always present and parseable. `okResult` still rejects empty timestamps, so call sites using the shared success helper must provide one, but some successful PM writes synthesise timestamps today: `PostComment` uses `currentTimestamp()` for `created` / `updated`, and `MoveWorkItem` can fall back through `pickTimestamp(undefined)` for `moved`. `noOpResult` and `abortedResult` synthesise via `currentTimestamp()` because no provider write happened — the synthetic "now" reflects when the gadget evaluated the guard. Read-back failures after a successful checklist mutation fall back to a synthesised URL + timestamp in `readWorkItemContext` rather than masking the mutation success and risking an idempotency retry storm (Trello native-checklist retries duplicate rows). + +The regression coverage lives in `tests/unit/cli/pm/pm-commands.test.ts`, `tests/unit/cli/scm/scm-commands.test.ts`, `tests/unit/gadgets/pm/definitions.test.ts`, and `tests/unit/gadgets/github/definitions.test.ts`. Run the focused suite with: + +```bash +npx vitest run --project unit-core \ + tests/unit/cli/pm/pm-commands.test.ts \ + tests/unit/cli/scm/scm-commands.test.ts \ + tests/unit/gadgets/pm/definitions.test.ts \ + tests/unit/gadgets/github/definitions.test.ts +``` + +The full pre-PR gate remains `npm run lint && npm run typecheck && npm test`. + ### Shell-safety contract (MNG-1059) cascade-tools commands that accept text bodies, descriptions, or markdown payloads declare a `--*-file ` companion via `cli.fileInputAlternatives` (`--body-file`, `--text-file`, `--description-file`, `--details-file`, `--comments-file`). Agents are instructed to prefer the file form for any content containing backticks, code fences, `$(...)`, or newlines — shells expand those tokens even inside single quotes when the command is layered through `bash -c`, and newlines break argv parsing. diff --git a/src/gadgets/README.md b/src/gadgets/README.md index 976612e0b..1d947dc37 100644 --- a/src/gadgets/README.md +++ b/src/gadgets/README.md @@ -142,6 +142,82 @@ Core gadget functions must throw for fatal runtime/API/provider failures. Do not --- +## Mutation result contract (MNG-1422 → MNG-1428) + +Every PM mutation core and the SCM PR comment/reply/update/review mutation cores covered by MNG-1428 return structured objects — never prose. The CLI factory serialises those objects verbatim into the `{ "success": true, "data": {...} }` stdout envelope, so consumers (downstream agents, sidecar tooling, review/respond workflows) can read structured keys without regex'ing sentence fragments. Mutation outcomes use the shared shapes declared in `src/gadgets/pm/core/mutationResults.ts` and `src/gadgets/github/core/mutationResults.ts`. + +### Mutation identity and status fields + +| Field | Meaning | +|---|---| +| `status` | The MUTATION OUTCOME — `"created"` / `"updated"` / `"moved"` / `"noop"` / `"aborted"` / `"deleted"` (PM) or `"ok"` / `"no-op"` / `"aborted"` (SCM). Branch on this, not on prose. | +| `updatedAt` | ISO 8601 timestamp string. It is always present and parseable; the source varies by mutation and fallback path. | + +Identity and URL fields are mutation-specific: + +- Work-item and comment mutations expose `id` plus their canonical resource URL (`url` or, for PM comments, `workItemUrl`). +- `AddChecklist` exposes `checklistId` and `workItemUrl`, plus `itemIds` / `itemCount`. +- `PMUpdateChecklistItem` and `PMDeleteChecklistItem` expose `checkItemId` and `workItemUrl`. +- Targeted SCM PR comment/reply/update/review mutations expose `id`, `url`, and the parent PR context: `repoFullName` (e.g. `"acme/myapp"`) and `prNumber` (or `number | null` for the rare issue-only `UpdatePRComment` case). `CreatePRReview` extends that shape with `reviewUrl`, `event`, `submittedAt`, and `inlineCommentCount`. +- `CreatePR` is also a structured SCM mutation, but it is outside MNG-1428's shared `status` / `updatedAt` / `id` / `url` contract and keeps its existing shape: `prNumber`, `prUrl`, `repoFullName`, and `alreadyExisted` plus optional commit/push details. + +### `status` vs `workflowStatus` naming — do not conflate + +`status` is reserved for the **mutation outcome** alone. The PM provider's **workflow state** (e.g. Linear's "In Progress", a Trello list name, a JIRA status) lives on its own keys: + +- `workflowStatus` (string, optional) — human-readable workflow state name. +- `workflowStatusId` (string, optional) — provider-native ID (Linear state UUID, Trello list ID). +- `previousStatus` / `previousStatusId` on `MoveWorkItem` — the work item's pre-move workflow state read back from the provider on the guarded path. + +A historical mix-up between the two surfaces cost ~2½ minutes of agent time once (prod run `5d993b04`) when an agent treated a Trello list name surfaced through a `status` key as a mutation outcome. The dual-key naming is now load-bearing — keep mutation outcomes and workflow states on separate fields. + +### Fatal failures throw — no prose sentinels + +Mutation cores propagate runtime / API / provider errors as thrown exceptions. The shared `createCLICommand()` factory wraps them in the spec-014 runtime envelope: + +```json +{ "success": false, "error": { "type": "runtime", "message": "Provider 422" } } +``` + +Do not return strings like `"Error creating work item: ..."` from a mutation core. The CLI cannot distinguish a sentinel-string return from a successful `data` payload, so the envelope would say `success: true` and the agent would silently mis-act on the prose. + +The only exceptions are intentional non-fatal outcomes that are part of the contract — e.g. `MoveWorkItem` returning `status: "noop"` when the work item is already in the destination, or `ReportFriction` returning `status: "queued_slot_missing"` when the friction slot isn't configured. These are structured returns, not prose sentinels. + +### Timestamp fallback semantics + +The stable contract is that `updatedAt` is present and parseable. Its source varies: + +- `okResult(providerTs)` still rejects empty timestamps, so call sites that use the shared success helper must provide a timestamp. +- Some successful PM writes synthesise timestamps today: `PostComment` uses `currentTimestamp()` for its `created` / `updated` outcomes, and `MoveWorkItem` can fall back through `pickTimestamp(undefined)` for `moved`. +- `"noop"` / `"aborted"` outcomes synthesise via `currentTimestamp()` because no provider write happened. The synthetic "now" reflects when the gadget evaluated the guard, not a provider write. +- Read-back failures after a successful checklist mutation fall back to a synthesised URL + timestamp inside `readWorkItemContext` rather than masking the mutation success and risking an idempotency retry storm (especially on Trello's native checklists, where retries duplicate rows). + +### Focused verification command (MNG-1428) + +The regression coverage for this contract lives in three test files. To re-run them in isolation: + +```bash +npx vitest run --project unit-core \ + tests/unit/cli/pm/pm-commands.test.ts \ + tests/unit/cli/scm/scm-commands.test.ts \ + tests/unit/gadgets/pm/definitions.test.ts \ + tests/unit/gadgets/github/definitions.test.ts +``` + +Each suite parses the CLI stdout envelope and asserts `success.data.status`, parseable `success.data.updatedAt`, and the mutation-specific identity/URL fields (`id` / `url`, `workItemUrl`, `checklistId`, or `checkItemId` as applicable, plus `repoFullName` / `prNumber` for targeted SCM PR comment/reply/update/review mutations). The suites also pin the runtime envelope shape for thrown core failures. The output-shape tests in the gadget-definition suites pin the `status` vs `workflowStatus` split as well. + +The full pre-PR gate is unchanged: + +```bash +npm run lint # biome check (also via lint:fix during iteration) +npm run typecheck # tsc --noEmit +npm test # all four unit projects +``` + +Changed surfaces touched by this contract: PM mutation cores under `src/gadgets/pm/core/`, targeted SCM PR comment/reply/update/review mutation cores under `src/gadgets/github/core/`, the matching CLI commands under `src/cli/pm/` and `src/cli/scm/`, and the `outputShape` blocks on the matching `ToolDefinition`s in `src/gadgets/{pm,github}/definitions.ts`. `CreatePR` remains documented and tested as its own structured mutation shape. + +--- + ## Shared CLI helper layout `createCLICommand()` is intentionally the stable public facade for command files under `src/cli/**`. Its implementation delegates to focused helpers under `src/gadgets/shared/cli/`: diff --git a/tests/unit/cli/pm/pm-commands.test.ts b/tests/unit/cli/pm/pm-commands.test.ts index 2bb76ddcc..5c34f9d9d 100644 --- a/tests/unit/cli/pm/pm-commands.test.ts +++ b/tests/unit/cli/pm/pm-commands.test.ts @@ -64,7 +64,19 @@ vi.mock('../../../../src/gadgets/pm/core/reportFriction.js', () => ({ vi.mock('../../../../src/gadgets/pm/core/postComment.js', () => ({ postComment: vi.fn().mockResolvedValue({ id: 'comment-1' }), })); +vi.mock('../../../../src/gadgets/pm/core/updateWorkItem.js', () => ({ + updateWorkItem: vi.fn().mockResolvedValue({ id: 'wi-1', status: 'updated' }), +})); +vi.mock('../../../../src/gadgets/pm/core/addChecklist.js', () => ({ + addChecklist: vi.fn().mockResolvedValue({ id: 'wi-1', status: 'created' }), +})); +// Suppress the PM-write sidecar side effect — the structured-output assertions +// only care about the CLI's JSON envelope. +vi.mock('../../../../src/gadgets/session/core/sidecar.js', () => ({ + writePMWriteSidecar: vi.fn(() => true), +})); +import AddChecklist from '../../../../src/cli/pm/add-checklist.js'; import CreateWorkItem from '../../../../src/cli/pm/create-work-item.js'; import DeleteChecklistItem from '../../../../src/cli/pm/delete-checklist-item.js'; import ListWorkItems from '../../../../src/cli/pm/list-work-items.js'; @@ -73,6 +85,8 @@ import PostComment from '../../../../src/cli/pm/post-comment.js'; import ReadWorkItem from '../../../../src/cli/pm/read-work-item.js'; import ReportFriction from '../../../../src/cli/pm/report-friction.js'; import UpdateChecklistItem from '../../../../src/cli/pm/update-checklist-item.js'; +import UpdateWorkItem from '../../../../src/cli/pm/update-work-item.js'; +import { addChecklist } from '../../../../src/gadgets/pm/core/addChecklist.js'; import { createWorkItem } from '../../../../src/gadgets/pm/core/createWorkItem.js'; import { deleteChecklistItem } from '../../../../src/gadgets/pm/core/deleteChecklistItem.js'; import { listWorkItems } from '../../../../src/gadgets/pm/core/listWorkItems.js'; @@ -81,6 +95,7 @@ import { postComment } from '../../../../src/gadgets/pm/core/postComment.js'; import { readWorkItem } from '../../../../src/gadgets/pm/core/readWorkItem.js'; import { reportFriction } from '../../../../src/gadgets/pm/core/reportFriction.js'; import { updateChecklistItem } from '../../../../src/gadgets/pm/core/updateChecklistItem.js'; +import { updateWorkItem } from '../../../../src/gadgets/pm/core/updateWorkItem.js'; /** Create a fresh minimal oclif config to satisfy this.parse() in each test */ function makeMockConfig() { @@ -473,3 +488,487 @@ describe('PostComment command (basic params)', () => { }); }); }); + +// --------------------------------------------------------------------------- +// MNG-1428: Structured-output contract regression coverage +// +// Each targeted PM mutation CLI must serialise the structured core result into +// the `{ success: true, data: ... }` envelope without rewriting it into a prose +// sentinel. These tests parse stdout and pin `success.data.id`, +// `success.data.url`, `success.data.status`, and `success.data.updatedAt` +// (where applicable) so a future renderer drift that drops a field surfaces +// loudly in CI instead of silently regressing the agent-facing contract. +// +// Read-only commands (read-work-item, list-work-items) are excluded — they +// have no mutation outcome and so no required `status` / `updatedAt` fields. +// --------------------------------------------------------------------------- +describe('PM CLI structured-output contract (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: Record; + error?: { type: string; message: string }; + }; + } + + it('CreateWorkItem stdout exposes id, url, status="created", and updatedAt', async () => { + vi.mocked(createWorkItem).mockResolvedValue({ + status: 'created', + id: 'wi-new', + title: 'New Card', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T12:00:00.000Z', + workflowStatus: 'Backlog', + workflowStatusId: 'list-backlog', + } as never); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + id: 'wi-new', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T12:00:00.000Z', + }); + // Provider-specific workflow state lives on its own keys — pinning the + // `status` vs `workflowStatus` naming so the mutation outcome is never + // confused with the workflow column name. + expect(output.data?.workflowStatus).toBe('Backlog'); + expect(output.data?.workflowStatusId).toBe('list-backlog'); + }); + + it('PostComment stdout exposes id, workItemUrl, status, and updatedAt', async () => { + vi.mocked(postComment).mockResolvedValue({ + status: 'created', + id: 'comment-42', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + } as never); + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Status update'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + id: 'comment-42', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + }); + }); + + it('PostComment exposes status="updated" when the progress comment was replaced', async () => { + vi.mocked(postComment).mockResolvedValue({ + status: 'updated', + id: 'comment-7', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T12:34:56.000Z', + } as never); + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Final summary'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('updated'); + expect(output.data?.id).toBe('comment-7'); + }); + + it('UpdateWorkItem stdout exposes id, url, status, updatedAt, and the changed-field arrays', async () => { + vi.mocked(updateWorkItem).mockResolvedValue({ + status: 'updated', + id: 'card-9', + title: 'Renamed', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['label-1'], + } as never); + const cmd = new UpdateWorkItem( + [ + '--workItemId', + 'card-9', + '--title', + 'Renamed', + '--description', + 'New body', + '--addLabelId', + 'label-1', + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'updated', + id: 'card-9', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: ['title', 'description'], + addedLabelIds: ['label-1'], + }); + }); + + it('UpdateWorkItem exposes status="noop" when no updates were supplied', async () => { + vi.mocked(updateWorkItem).mockResolvedValue({ + status: 'noop', + id: 'card-9', + title: '', + url: 'https://pm.example/card/card-9', + updatedAt: '2026-06-01T13:00:00.000Z', + changedFields: [], + addedLabelIds: [], + message: 'Nothing to update - provide title, description, or labels', + } as never); + const cmd = new UpdateWorkItem(['--workItemId', 'card-9'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('noop'); + expect(output.data?.changedFields).toEqual([]); + expect(output.data?.addedLabelIds).toEqual([]); + }); + + it('MoveWorkItem stdout exposes id, url, status="moved", and updatedAt', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'moved', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'moved', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + }); + }); + + it('MoveWorkItem exposes status="noop" with previousStatus when already in destination', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'noop', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + previousStatus: 'Done', + previousStatusId: 'list-done', + message: "Work item already in destination state 'Done' — no-op", + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done', '--expectedSourceState', 'Backlog'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('noop'); + expect(output.data?.previousStatus).toBe('Done'); + expect(output.data?.previousStatusId).toBe('list-done'); + }); + + it('MoveWorkItem exposes status="aborted" when the guard rejected the move', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ + status: 'aborted', + id: 'card-2', + url: 'https://pm.example/card/card-2', + destination: 'list-done', + updatedAt: '2026-06-01T14:00:00.000Z', + previousStatus: 'In Progress', + message: "Aborted: expected 'Backlog', found 'In Progress'", + } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done', '--expectedSourceState', 'Backlog'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.data?.status).toBe('aborted'); + expect(output.data?.previousStatus).toBe('In Progress'); + }); + + it('AddChecklist stdout exposes checklistId, workItemUrl, itemIds, itemCount, status, and updatedAt', async () => { + vi.mocked(addChecklist).mockResolvedValue({ + status: 'created', + checklistId: 'cl-1', + checklistName: 'Acceptance Criteria', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T15:00:00.000Z', + itemCount: 2, + itemIds: ['item-1', 'item-2'], + } as never); + // AddChecklist's --item param is declared as `array of object`, so the + // CLI factory expects a single JSON-encoded array payload. + const cmd = new AddChecklist( + [ + '--workItemId', + 'card-1', + '--checklistName', + 'Acceptance Criteria', + '--item', + JSON.stringify([{ name: 'First step' }, { name: 'Second step' }]), + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'created', + checklistId: 'cl-1', + checklistName: 'Acceptance Criteria', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + updatedAt: '2026-06-01T15:00:00.000Z', + itemCount: 2, + itemIds: ['item-1', 'item-2'], + }); + }); + + it('UpdateChecklistItem stdout exposes workItemUrl, checkItemId, status="updated", complete, and updatedAt', async () => { + vi.mocked(updateChecklistItem).mockResolvedValue({ + status: 'updated', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + complete: true, + updatedAt: '2026-06-01T16:00:00.000Z', + } as never); + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'updated', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + complete: true, + updatedAt: '2026-06-01T16:00:00.000Z', + }); + }); + + it('PMDeleteChecklistItem stdout exposes workItemUrl, checkItemId, status="deleted", and updatedAt', async () => { + vi.mocked(deleteChecklistItem).mockResolvedValue({ + status: 'deleted', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + updatedAt: '2026-06-01T16:30:00.000Z', + } as never); + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'deleted', + workItemId: 'card-1', + workItemUrl: 'https://pm.example/card/card-1', + checkItemId: 'item-456', + updatedAt: '2026-06-01T16:30:00.000Z', + }); + }); + + it('updatedAt values are ISO 8601 strings (regression guard against renderer drift)', async () => { + // Pins the timestamp surface contract: cores prefer provider-supplied + // timestamps and fall back to `currentTimestamp()` for synthetic outcomes. + // Either way the CLI envelope must carry a parseable ISO 8601 string, + // not a Date instance or a free-form prose value. + vi.mocked(createWorkItem).mockResolvedValue({ + status: 'created', + id: 'wi-new', + title: 'X', + url: 'https://pm.example/card/wi-new', + updatedAt: '2026-06-01T17:00:00.000Z', + } as never); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'X'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(typeof output.data?.updatedAt).toBe('string'); + expect(Number.isNaN(Date.parse(output.data?.updatedAt as string))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// MNG-1428: Runtime failure envelopes +// +// Each PM mutation must surface fatal core errors as the spec-014 runtime +// envelope (`{ success: false, error: { type: 'runtime', message: ... } }`) +// — never as a successful prose sentinel like +// `"Error creating work item: ..."`. These tests pin the CLI translation per +// command so a regression that reverts to prose surfaces immediately. +// --------------------------------------------------------------------------- +describe('PM CLI runtime failure envelopes (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: unknown; + error?: { type: string; message: string }; + }; + } + + it('CreateWorkItem surfaces a runtime envelope when createWorkItem throws', async () => { + vi.mocked(createWorkItem).mockRejectedValueOnce(new Error('Provider 403')); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 403' }); + expect(output.data).toBeUndefined(); + }); + + it('UpdateWorkItem surfaces a runtime envelope when updateWorkItem throws', async () => { + vi.mocked(updateWorkItem).mockRejectedValueOnce(new Error('Provider 422')); + const cmd = new UpdateWorkItem( + ['--workItemId', 'card-9', '--title', 'New'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 422' }); + }); + + it('MoveWorkItem surfaces a runtime envelope when moveWorkItem throws', async () => { + vi.mocked(moveWorkItem).mockRejectedValueOnce(new Error('Provider 500')); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-2', '--destination', 'list-done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 500' }); + }); + + it('AddChecklist surfaces a runtime envelope when addChecklist throws', async () => { + vi.mocked(addChecklist).mockRejectedValueOnce(new Error('Provider 429')); + const cmd = new AddChecklist( + [ + '--workItemId', + 'card-1', + '--checklistName', + 'CL', + '--item', + JSON.stringify([{ name: 'step' }]), + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 429' }); + }); + + it('UpdateChecklistItem surfaces a runtime envelope when updateChecklistItem throws', async () => { + vi.mocked(updateChecklistItem).mockRejectedValueOnce(new Error('Provider 503')); + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 503' }); + }); + + it('PMDeleteChecklistItem surfaces a runtime envelope when deleteChecklistItem throws', async () => { + vi.mocked(deleteChecklistItem).mockRejectedValueOnce(new Error('Provider 404')); + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-1', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + const exitSpy = vi.spyOn(cmd, 'exit'); + + await runExpectingExit(cmd); + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error).toEqual({ type: 'runtime', message: 'Provider 404' }); + }); +}); diff --git a/tests/unit/cli/scm/scm-commands.test.ts b/tests/unit/cli/scm/scm-commands.test.ts index 26bd0ce2f..ac064521d 100644 --- a/tests/unit/cli/scm/scm-commands.test.ts +++ b/tests/unit/cli/scm/scm-commands.test.ts @@ -17,8 +17,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Mock credential-scoping dependencies // --------------------------------------------------------------------------- +// CreatePRReview also calls the GitHub client directly to delete the ack +// comment after a successful review submission, so `githubClient.deletePRComment` +// must be defined here for that code path. vi.mock('../../../../src/github/client.js', () => ({ withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), + githubClient: { + deletePRComment: vi.fn().mockResolvedValue(undefined), + }, })); vi.mock('../../../../src/trello/client.js', () => ({ withTrelloCredentials: vi.fn( @@ -62,7 +68,16 @@ vi.mock('../../../../src/gadgets/github/core/replyToReviewComment.js', () => ({ vi.mock('../../../../src/gadgets/github/core/updatePRComment.js', () => ({ updatePRComment: vi.fn().mockResolvedValue({ id: 300, body: 'Updated' }), })); +vi.mock('../../../../src/gadgets/github/core/createPRReview.js', () => ({ + createPRReview: vi.fn().mockResolvedValue({ id: '400', reviewUrl: 'https://gh/r/400' }), +})); +// Suppress sidecar side effects so the structured-output assertions stay +// focused on the CLI's JSON envelope. +vi.mock('../../../../src/gadgets/session/core/sidecar.js', () => ({ + writeReviewSidecar: vi.fn(() => true), +})); +import CreatePRReview from '../../../../src/cli/scm/create-pr-review.js'; import GetCIRunLogs from '../../../../src/cli/scm/get-ci-run-logs.js'; import GetPRChecks from '../../../../src/cli/scm/get-pr-checks.js'; import GetPRComments from '../../../../src/cli/scm/get-pr-comments.js'; @@ -71,6 +86,7 @@ import GetPRDiff from '../../../../src/cli/scm/get-pr-diff.js'; import PostPRComment from '../../../../src/cli/scm/post-pr-comment.js'; import ReplyToReviewComment from '../../../../src/cli/scm/reply-to-review-comment.js'; import UpdatePRComment from '../../../../src/cli/scm/update-pr-comment.js'; +import { createPRReview } from '../../../../src/gadgets/github/core/createPRReview.js'; import { getCIRunLogs } from '../../../../src/gadgets/github/core/getCIRunLogs.js'; import { getPRChecks } from '../../../../src/gadgets/github/core/getPRChecks.js'; import { getPRComments } from '../../../../src/gadgets/github/core/getPRComments.js'; @@ -526,3 +542,236 @@ describe('SCM CLI runtime failure envelopes (MNG-1425)', () => { expect(output.error?.message).toBe('Unprocessable Entity'); }); }); + +// --------------------------------------------------------------------------- +// MNG-1428: SCM CLI structured-output regression coverage +// +// Each targeted SCM mutation CLI (post-pr-comment / update-pr-comment / +// reply-to-review-comment / create-pr-review) must serialise the GitHub +// mutation result into the `{ success: true, data: ... }` envelope and carry +// the minimum structured contract — `success.data.id`, `success.data.url`, +// `success.data.status`, `success.data.updatedAt`, plus the PR/repo context +// (`repoFullName`, `prNumber`). These tests parse stdout and pin each field so +// a future renderer drift surfaces in CI rather than silently regressing the +// agent-facing contract. +// +// CreatePRReview also exposes `reviewUrl`, `event`, `submittedAt`, and +// `inlineCommentCount` — pinned here too because review workflows downstream +// consume those keys directly from the structured envelope. +// --------------------------------------------------------------------------- +describe('SCM CLI structured-output contract (MNG-1428)', () => { + function readJsonOutput(logSpy: ReturnType) { + const lines = logSpy.mock.calls.map((c) => c[0] as string); + const jsonLine = lines.find((l) => typeof l === 'string' && l.startsWith('{')) ?? ''; + return JSON.parse(jsonLine) as { + success: boolean; + data?: Record; + error?: { type: string; message: string }; + }; + } + + /** + * Runtime failures emit the envelope, then call exit(1). Oclif's exit + * surfaces as a thrown EEXIT error from `cmd.run()`. Mirrors the helper + * scoped to the MNG-1425 describe — local copy avoids leaking state. + */ + async function runExpectingExit(cmd: { run: () => Promise }): Promise { + try { + await cmd.run(); + } catch (err) { + const status = (err as { oclif?: { exit?: number }; code?: string })?.oclif?.exit; + const code = (err as { code?: string })?.code; + if (status === 1 || code === 'EEXIT') return; + throw err; + } + } + + it('PostPRComment stdout exposes id, url, status="ok", updatedAt, repoFullName, prNumber', async () => { + vi.mocked(postPRComment).mockResolvedValueOnce({ + status: 'ok', + id: '987654321', + url: 'https://github.com/owner/repo/pull/42#issuecomment-987654321', + updatedAt: '2026-06-01T18:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Working on it...'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '987654321', + url: 'https://github.com/owner/repo/pull/42#issuecomment-987654321', + updatedAt: '2026-06-01T18:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('UpdatePRComment stdout exposes id, url, status, updatedAt, repoFullName, prNumber', async () => { + vi.mocked(updatePRComment).mockResolvedValueOnce({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/pull/42#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new UpdatePRComment( + ['--commentId', '111222333', '--body', 'Updated'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/pull/42#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('UpdatePRComment accepts prNumber=null when the comment is not on a PR thread', async () => { + // The UpdatePRComment contract specifies prNumber as `number | null` — + // pinned in `updatePRCommentDef.outputShape` — because some issue-only + // comments don't expose `/pull/` in their html_url. This test makes + // sure the CLI envelope round-trips that nullable value. + vi.mocked(updatePRComment).mockResolvedValueOnce({ + status: 'ok', + id: '111222333', + url: 'https://github.com/owner/repo/issues/9#issuecomment-111222333', + updatedAt: '2026-06-01T18:30:00.000Z', + repoFullName: 'owner/repo', + prNumber: null, + } as never); + const cmd = new UpdatePRComment( + ['--commentId', '111222333', '--body', 'Updated'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data?.prNumber).toBeNull(); + }); + + it('ReplyToReviewComment stdout exposes id, url, status, updatedAt, repoFullName, prNumber', async () => { + vi.mocked(replyToReviewComment).mockResolvedValueOnce({ + status: 'ok', + id: '500', + url: 'https://github.com/owner/repo/pull/42#discussion_r500', + updatedAt: '2026-06-01T19:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '12345', '--body', 'Done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '500', + url: 'https://github.com/owner/repo/pull/42#discussion_r500', + updatedAt: '2026-06-01T19:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + }); + }); + + it('CreatePRReview stdout exposes id, url, status, updatedAt, repoFullName, prNumber, reviewUrl, event, submittedAt, inlineCommentCount', async () => { + vi.mocked(createPRReview).mockResolvedValueOnce({ + status: 'ok', + id: '700', + url: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + updatedAt: '2026-06-01T20:00:00.000Z', + reviewUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + event: 'REQUEST_CHANGES', + repoFullName: 'owner/repo', + prNumber: 42, + submittedAt: '2026-06-01T20:00:00.000Z', + inlineCommentCount: 1, + } as never); + const cmd = new CreatePRReview( + [ + '--prNumber', + '42', + '--event', + 'REQUEST_CHANGES', + '--body', + 'Please address inline comments.', + ], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(true); + expect(output.data).toMatchObject({ + status: 'ok', + id: '700', + url: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + updatedAt: '2026-06-01T20:00:00.000Z', + reviewUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-700', + event: 'REQUEST_CHANGES', + repoFullName: 'owner/repo', + prNumber: 42, + submittedAt: '2026-06-01T20:00:00.000Z', + inlineCommentCount: 1, + }); + }); + + it('CreatePRReview surfaces a runtime envelope when createPRReview throws (MNG-1425 + MNG-1428)', async () => { + vi.mocked(createPRReview).mockRejectedValueOnce(new Error('Validation Failed')); + const cmd = new CreatePRReview( + ['--prNumber', '42', '--event', 'APPROVE', '--body', 'LGTM'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await runExpectingExit(cmd); + + const output = readJsonOutput(logSpy); + expect(output.success).toBe(false); + expect(output.error?.type).toBe('runtime'); + expect(output.error?.message).toBe('Validation Failed'); + }); + + it('updatedAt values are ISO 8601 strings across SCM mutations', async () => { + // Pins the GitHub-supplied timestamp surface — postPRComment / replyToReviewComment / + // updatePRComment use the response's `updated_at`; createPRReview falls back through + // pickTimestamp(submitted_at). The CLI envelope must carry parseable ISO 8601 strings + // either way. + vi.mocked(postPRComment).mockResolvedValueOnce({ + status: 'ok', + id: '1', + url: 'https://github.com/owner/repo/pull/42#issuecomment-1', + updatedAt: '2026-06-01T21:00:00.000Z', + repoFullName: 'owner/repo', + prNumber: 42, + } as never); + const cmd = new PostPRComment(['--prNumber', '42', '--body', 'hi'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = readJsonOutput(logSpy); + expect(typeof output.data?.updatedAt).toBe('string'); + expect(Number.isNaN(Date.parse(output.data?.updatedAt as string))).toBe(false); + }); +}); diff --git a/tests/unit/gadgets/github/definitions.test.ts b/tests/unit/gadgets/github/definitions.test.ts index 2fd37929d..e7cba681a 100644 --- a/tests/unit/gadgets/github/definitions.test.ts +++ b/tests/unit/gadgets/github/definitions.test.ts @@ -417,6 +417,73 @@ describe('GitHub mutation output shapes (MNG-1427)', () => { }); }); +// --------------------------------------------------------------------------- +// MNG-1428: SCM minimum structured-output contract +// +// Every SCM PR comment / reply / update / review mutation must declare the +// minimum structured-output fields (`status`, `id`, `url`, `updatedAt`) plus +// the PR/repo context (`repoFullName`, `prNumber`). These tests pin each +// minimum field as a regression guard — a future drift that drops a key from +// outputShape (or changes the type) surfaces in CI rather than silently +// breaking the documented agent-facing contract. +// +// The CLI envelope round-trips these fields verbatim through stdout; consumers +// (CLI sidecars, downstream review/respond flows) read them as structured +// data and don't have to parse prose. +// --------------------------------------------------------------------------- +describe('SCM minimum structured-output contract (MNG-1428)', () => { + const PR_MUTATION_DEFS = [ + postPRCommentDef, + updatePRCommentDef, + replyToReviewCommentDef, + createPRReviewDef, + ]; + + for (const def of PR_MUTATION_DEFS) { + describe(`${def.name}`, () => { + it('declares the minimum structured-output fields (id, url, status, updatedAt)', () => { + const fieldsByName = new Map((def.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect(fieldsByName.get('id'), `${def.name} must declare id`).toBeDefined(); + expect(fieldsByName.get('id')?.type).toBe('string'); + expect(fieldsByName.get('url'), `${def.name} must declare url`).toBeDefined(); + expect(fieldsByName.get('url')?.type).toBe('string'); + expect(fieldsByName.get('status'), `${def.name} must declare status`).toBeDefined(); + expect(fieldsByName.get('updatedAt'), `${def.name} must declare updatedAt`).toBeDefined(); + expect(fieldsByName.get('updatedAt')?.type).toBe('string'); + }); + + it('declares the PR/repo context fields (repoFullName, prNumber)', () => { + const fieldsByName = new Map((def.outputShape?.fields ?? []).map((f) => [f.name, f])); + expect( + fieldsByName.get('repoFullName'), + `${def.name} must declare repoFullName`, + ).toBeDefined(); + expect(fieldsByName.get('repoFullName')?.type).toBe('string'); + expect(fieldsByName.get('prNumber'), `${def.name} must declare prNumber`).toBeDefined(); + // UpdatePRComment widens to `number | null` because some issue-only + // comments don't expose a /pull/ segment in html_url — but the + // field is still always present on the output shape. + expect(['number', 'number | null']).toContain(fieldsByName.get('prNumber')?.type); + }); + + it('declares the generic GitHub mutation status union on `status`', () => { + const status = def.outputShape?.fields.find((f) => f.name === 'status'); + // All four PR-mutation outputs reuse the shared + // `GitHubMutationStatus` union (`"ok" | "no-op" | "aborted"`). + expect(status?.type).toBe('"ok" | "no-op" | "aborted"'); + }); + }); + } + + it('CreatePRReview additionally declares reviewUrl, event, submittedAt, inlineCommentCount', () => { + const names = createPRReviewDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).toContain('reviewUrl'); + expect(names).toContain('event'); + expect(names).toContain('submittedAt'); + expect(names).toContain('inlineCommentCount'); + }); +}); + // --------------------------------------------------------------------------- // Spec 014 plan 2: createPRReviewDef declarative opt-in // --------------------------------------------------------------------------- diff --git a/tests/unit/gadgets/pm/definitions.test.ts b/tests/unit/gadgets/pm/definitions.test.ts index 862b0b962..387fbd821 100644 --- a/tests/unit/gadgets/pm/definitions.test.ts +++ b/tests/unit/gadgets/pm/definitions.test.ts @@ -412,4 +412,103 @@ describe('PM gadget definitions', () => { expect(status?.type).toBe('"deleted"'); }); }); + + // ─── Structured-output naming contract (MNG-1428) ───────────────────────── + // + // `status` is reserved for the MUTATION OUTCOME (`"created"`, `"updated"`, + // `"moved"`, `"noop"`, `"aborted"`, `"deleted"`). The PROVIDER WORKFLOW + // STATE (e.g. Linear "In Progress", Trello list "Backlog") lives on its + // own keys — `workflowStatus` (human-readable) and `workflowStatusId` + // (native ID). Mixing the two cost ~2½ minutes of agent run time once + // (prod run 5d993b04) when an agent treated a Trello list name returned + // in `status` as a mutation outcome. These tests pin the split so a + // future drift can never silently collapse the two surfaces. + describe('status vs workflowStatus naming contract (MNG-1428)', () => { + it('CreateWorkItem distinguishes mutation `status` from provider `workflowStatus`', () => { + const fieldsByName = new Map( + (createWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + // Mutation outcome — required, always "created" on success. + expect(fieldsByName.get('status')?.type).toBe('"created"'); + expect(fieldsByName.get('status')?.optional).toBeFalsy(); + // Provider workflow state — optional human-readable name. + expect(fieldsByName.get('workflowStatus')?.type).toBe('string'); + expect(fieldsByName.get('workflowStatus')?.optional).toBe(true); + // Provider workflow state — optional native ID. + expect(fieldsByName.get('workflowStatusId')?.type).toBe('string'); + expect(fieldsByName.get('workflowStatusId')?.optional).toBe(true); + }); + + it('MoveWorkItem distinguishes mutation `status` from `previousStatus` / `previousStatusId`', () => { + const fieldsByName = new Map( + (moveWorkItemDef.outputShape?.fields ?? []).map((f) => [f.name, f]), + ); + // Mutation outcome — required, union of allowed move outcomes. + expect(fieldsByName.get('status')?.type).toBe('"moved" | "noop" | "aborted"'); + expect(fieldsByName.get('status')?.optional).toBeFalsy(); + // Provider workflow read-back values — optional, distinct keys. + expect(fieldsByName.get('previousStatus')?.optional).toBe(true); + expect(fieldsByName.get('previousStatusId')?.optional).toBe(true); + }); + + // Linear has no custom-field concept and so does not surface a workflow + // status on AddChecklist — but the naming-collision guard still applies: + // `status` must remain the mutation outcome, not the parent work item's + // workflow state. + it('AddChecklist `status` field is the mutation outcome (always "created")', () => { + const status = addChecklistDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"created"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PMUpdateChecklistItem `status` field is the mutation outcome (always "updated")', () => { + const status = pmUpdateChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"updated"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PMDeleteChecklistItem `status` field is the mutation outcome (always "deleted")', () => { + const status = pmDeleteChecklistItemDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"deleted"'); + expect(status?.optional).toBeFalsy(); + }); + + it('PostComment `status` is the comment-mutation outcome, not a workflow state', () => { + const status = postCommentDef.outputShape?.fields.find((f) => f.name === 'status'); + expect(status?.type).toBe('"created" | "updated"'); + // PostComment does not carry workflowStatus at all — the parent work + // item's state is irrelevant to a comment write. + const names = postCommentDef.outputShape?.fields.map((f) => f.name) ?? []; + expect(names).not.toContain('workflowStatus'); + expect(names).not.toContain('workflowStatusId'); + }); + }); + + // ─── Timestamp surface contract (MNG-1428) ──────────────────────────────── + // + // Every PM mutation outputShape must declare an `updatedAt` ISO 8601 field + // — provider-supplied on `"created"`/`"updated"`/`"moved"`/`"deleted"` + // outcomes, synthesised via `currentTimestamp()` for `"noop"`/`"aborted"`. + // The CLI envelope round-trips that string verbatim; downstream consumers + // rely on it being a parseable ISO 8601 timestamp. + describe('updatedAt field is present on every mutation outputShape (MNG-1428)', () => { + const MUTATION_DEFS = [ + postCommentDef, + updateWorkItemDef, + createWorkItemDef, + moveWorkItemDef, + addChecklistDef, + pmUpdateChecklistItemDef, + pmDeleteChecklistItemDef, + ]; + + for (const def of MUTATION_DEFS) { + it(`${def.name} declares an updatedAt field with type "string"`, () => { + const updatedAt = def.outputShape?.fields.find((f) => f.name === 'updatedAt'); + expect(updatedAt, `${def.name} must declare an updatedAt field`).toBeDefined(); + expect(updatedAt?.type).toBe('string'); + expect(updatedAt?.optional).toBeFalsy(); + }); + } + }); }); From 1bc08706f9118152a07c683c9e0734e9e3ec252b Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 2 Jun 2026 18:46:47 +0200 Subject: [PATCH 07/25] feat(gadgets): shared CLI fuzzy-matching helper (MNG-1440) (#1393) * feat(gadgets): shared CLI fuzzy-matching helper (MNG-1440) * fix: address feedback --------- Co-authored-by: Cascade Bot --- src/gadgets/shared/cli/parseErrors.ts | 27 +++---- src/gadgets/shared/cli/suggestions.ts | 70 +++++++++++++++++++ .../gadgets/shared/cli/parseErrors.test.ts | 6 ++ .../gadgets/shared/cli/suggestions.test.ts | 56 +++++++++++++++ 4 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 src/gadgets/shared/cli/suggestions.ts create mode 100644 tests/unit/gadgets/shared/cli/suggestions.test.ts diff --git a/src/gadgets/shared/cli/parseErrors.ts b/src/gadgets/shared/cli/parseErrors.ts index 8754a9f92..9680af322 100644 --- a/src/gadgets/shared/cli/parseErrors.ts +++ b/src/gadgets/shared/cli/parseErrors.ts @@ -1,33 +1,28 @@ -import { distance } from 'fastest-levenshtein'; - import type { EmitCliErrorOptions } from '../errorEnvelope.js'; - -const MAX_FLAG_SUGGESTION_DISTANCE = 2; -const MAX_FLAG_SUGGESTION_RATIO = 0.4; +import { suggestClosestCandidate } from './suggestions.js'; /** * For the given unknown flag and the command's declared flag names + aliases, * return the Levenshtein-closest canonical declared name if it passes the * distance threshold; otherwise null. + * + * Delegates scoring to the generic suggestion helper while preserving the + * canonical-flag-name return contract: when an alias is the closest match, + * the canonical name it points at is returned (so the agent always sees a real + * flag spelling, not an alias). */ export function suggestFlag( unknown: string, candidates: { canonical: string; aliases: readonly string[] }[], ): string | null { - let best: { canonical: string; dist: number } | null = null; + const names: { name: string; canonical: string; ratioBasis: string }[] = []; for (const { canonical, aliases } of candidates) { - for (const candidate of [canonical, ...aliases]) { - const d = distance(unknown, candidate); - if (best === null || d < best.dist) { - best = { canonical, dist: d }; - } + for (const name of [canonical, ...aliases]) { + names.push({ name, canonical, ratioBasis: canonical }); } } - if (best === null) return null; - const target = Math.max(unknown.length, best.canonical.length); - if (best.dist > MAX_FLAG_SUGGESTION_DISTANCE) return null; - if (target > 0 && best.dist / target > MAX_FLAG_SUGGESTION_RATIO) return null; - return best.canonical; + const closest = suggestClosestCandidate(unknown, names); + return closest?.canonical ?? null; } /** diff --git a/src/gadgets/shared/cli/suggestions.ts b/src/gadgets/shared/cli/suggestions.ts new file mode 100644 index 000000000..928eb42ba --- /dev/null +++ b/src/gadgets/shared/cli/suggestions.ts @@ -0,0 +1,70 @@ +import { distance } from 'fastest-levenshtein'; + +/** + * Maximum Levenshtein distance allowed between an unknown input and a + * suggested candidate. Distances above this are treated as "too far" and + * suppress the suggestion entirely. + */ +export const MAX_SUGGESTION_DISTANCE = 2; + +/** + * Maximum ratio of (distance / longer length) between an unknown input and a + * suggested candidate. Guards against weak matches on very short candidates + * (e.g. a distance of 2 on a 3-letter word, which would otherwise pass the + * distance gate but is statistically meaningless). + */ +export const MAX_SUGGESTION_RATIO = 0.4; + +export interface SuggestionCandidate { + name: string; + /** + * Optional spelling to use for the ratio gate after the candidate wins by + * edit distance. Defaults to `name`. + */ + ratioBasis?: string; +} + +/** + * Return the Levenshtein-closest candidate to `unknown`, provided the + * distance falls within the suggestion budget (distance `<= 2` and ratio + * `< 0.4` of the longer length). Returns `null` when no candidate is close + * enough to be a plausible typo or when the candidate list is empty. + * + * The helper is intentionally pure and string-only so it can power flag + * suggestions, command-name suggestions, and any other CLI ergonomics + * without loading oclif command classes. + * + * Ties are broken by input order: the first candidate matching the best + * (lowest) distance wins. + */ +export function suggestClosest(unknown: string, candidates: readonly string[]): string | null { + const closest = suggestClosestCandidate( + unknown, + candidates.map((name) => ({ name })), + ); + return closest?.name ?? null; +} + +/** + * Return the closest structured candidate using `name` for distance scoring. + * `ratioBasis` lets callers keep legacy canonical-name gating while matching + * aliases by edit distance. + */ +export function suggestClosestCandidate( + unknown: string, + candidates: readonly TCandidate[], +): TCandidate | null { + let best: { candidate: TCandidate; dist: number } | null = null; + for (const candidate of candidates) { + const d = distance(unknown, candidate.name); + if (best === null || d < best.dist) { + best = { candidate, dist: d }; + } + } + if (best === null) return null; + const ratioBasis = best.candidate.ratioBasis ?? best.candidate.name; + const target = Math.max(unknown.length, ratioBasis.length); + if (best.dist > MAX_SUGGESTION_DISTANCE) return null; + if (target > 0 && best.dist / target > MAX_SUGGESTION_RATIO) return null; + return best.candidate; +} diff --git a/tests/unit/gadgets/shared/cli/parseErrors.test.ts b/tests/unit/gadgets/shared/cli/parseErrors.test.ts index 4270b784b..a1e280be2 100644 --- a/tests/unit/gadgets/shared/cli/parseErrors.test.ts +++ b/tests/unit/gadgets/shared/cli/parseErrors.test.ts @@ -17,6 +17,12 @@ describe('CLI parse errors', () => { expect(suggestFlag('zzzzzzzz', [{ canonical: 'comments', aliases: ['comment'] }])).toBeNull(); }); + it('uses the canonical flag length for alias ratio gating', () => { + expect(suggestFlag('y', [{ canonical: 'long-flag-name', aliases: ['x'] }])).toBe( + 'long-flag-name', + ); + }); + it('recognizes oclif nonexistent-flag error shapes', () => { class NonExistentFlagsError extends Error { public flags = ['coment']; diff --git a/tests/unit/gadgets/shared/cli/suggestions.test.ts b/tests/unit/gadgets/shared/cli/suggestions.test.ts new file mode 100644 index 000000000..5697c78fc --- /dev/null +++ b/tests/unit/gadgets/shared/cli/suggestions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_SUGGESTION_DISTANCE, + MAX_SUGGESTION_RATIO, + suggestClosest, +} from '../../../../../src/gadgets/shared/cli/suggestions.js'; + +describe('suggestClosest', () => { + it('suggests the closest candidate when a single typo is within the distance budget', () => { + expect(suggestClosest('coment', ['comments', 'body'])).toBe('comments'); + }); + + it('suggests an exact match when present', () => { + expect(suggestClosest('comments', ['comments', 'body'])).toBe('comments'); + }); + + it('breaks ties by input order (first equally-close candidate wins)', () => { + // distance('foo', 'fop') === distance('foo', 'foop') === 1, so the + // iteration order of the candidate array decides which one wins. This + // matches the existing canonical-before-alias tie-breaking that + // `suggestFlag()` depends on. + expect(suggestClosest('foo', ['fop', 'foop'])).toBe('fop'); + expect(suggestClosest('foo', ['foop', 'fop'])).toBe('foop'); + }); + + it('returns null when no candidate is within the distance budget', () => { + // Distance from 'zzzzzzzz' to either candidate is 8, well past the budget. + expect(suggestClosest('zzzzzzzz', ['comments', 'body'])).toBeNull(); + }); + + it('returns null for an empty candidate list', () => { + expect(suggestClosest('foo', [])).toBeNull(); + }); + + it('rejects far matches even when the distance gate alone would pass on short candidates', () => { + // distance('foo', 'bar') === 3 → fails the distance gate already. + expect(suggestClosest('foo', ['bar'])).toBeNull(); + }); + + it('rejects matches that pass the distance gate but fail the ratio gate', () => { + // distance('ab', 'cd') === 2 — passes MAX_SUGGESTION_DISTANCE (2) but the + // ratio is 2/2 = 1.0, which is well above MAX_SUGGESTION_RATIO (0.4). + expect(suggestClosest('ab', ['cd'])).toBeNull(); + }); + + it('handles an empty unknown string by returning null when no candidate is close enough', () => { + // distance('', 'body') === 4 → fails the distance gate. + expect(suggestClosest('', ['body'])).toBeNull(); + }); + + it('keeps the documented thresholds stable so flag + future command suggestions agree', () => { + expect(MAX_SUGGESTION_DISTANCE).toBe(2); + expect(MAX_SUGGESTION_RATIO).toBe(0.4); + }); +}); From 0988dbfb0d7a84ed713fb17d59b90d7d4ffddd88 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 2 Jun 2026 19:12:41 +0200 Subject: [PATCH 08/25] feat(cli): unknown-command suggestions for cascade-tools (MNG-1441) (#1394) Co-authored-by: Cascade Bot --- src/cli/_shared/commandSuggestions.ts | 239 +++++++++++++++++++++ src/gadgets/shared/errorEnvelope.ts | 13 +- tests/unit/cli/command-suggestions.test.ts | 224 +++++++++++++++++++ 3 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 src/cli/_shared/commandSuggestions.ts create mode 100644 tests/unit/cli/command-suggestions.test.ts diff --git a/src/cli/_shared/commandSuggestions.ts b/src/cli/_shared/commandSuggestions.ts new file mode 100644 index 000000000..df79f891c --- /dev/null +++ b/src/cli/_shared/commandSuggestions.ts @@ -0,0 +1,239 @@ +/** + * Pure helper that builds the `unknown-command` CLI envelope options for a + * typoed `cascade-tools` invocation, given an oclif-like config. The helper + * is intentionally side-effect free — it does NOT install oclif's + * `command_not_found` hook, instantiate provider clients, or load command + * classes — so unit tests can pin the suggestion decisions without booting + * the full CLI surface. + * + * Suggestions are derived strictly from the loaded oclif config: + * + * - **Top-level topics** come from the union of (a) the first segment of + * every entry in `config.commandIDs` and (b) the keys of + * `config.pjson.oclif.topics` (skipping `hidden: true` topics). This makes + * the candidate set match the actual binary surface — the dashboard topic + * is excluded automatically when running under `cascade-tools`, because + * `bin/cascade-tools.js` excludes the dashboard glob from oclif's command + * discovery and overrides `pjson.oclif.topics` to a four-entry whitelist. + * - **Subcommands** for a known topic come from `config.commandIDs` entries + * that start with `:`, taking the next segment as the subcommand + * name. + * + * Hints are formatted with spaces (the cascade-tools topicSeparator), e.g. + * `cascade-tools pm read-work-item`, so the agent can copy-paste them + * directly into the next attempt. + * + * Distance + ratio thresholds are imported from the shared scorer at + * `gadgets/shared/cli/suggestions.ts` (MNG-1440) so command and flag + * suggestions stay calibrated against the same budget. The local + * `suggestClosestViable` variant in this file applies those thresholds + * with closest-VIABLE tie-breaking instead of closest-then-validate — + * see its docstring for why command topics with mixed lengths require + * that adjustment. + */ + +import { distance } from 'fastest-levenshtein'; + +import { + MAX_SUGGESTION_DISTANCE, + MAX_SUGGESTION_RATIO, +} from '../../gadgets/shared/cli/suggestions.js'; +import type { EmitCliErrorOptions } from '../../gadgets/shared/errorEnvelope.js'; + +/** + * Minimal shape of the `@oclif/core` `Config` object this helper needs. + * + * Kept narrow on purpose: declaring the full `Config` type would force unit + * tests to construct (or mock) a value that satisfies dozens of unrelated + * fields. Anything not used by the helper stays off the contract. + */ +export interface OclifLikeConfig { + /** CLI binary name, e.g. `'cascade-tools'`. Used to format runnable hints. */ + readonly bin: string; + /** + * All loaded command IDs, with `:` as topic separator. Oclif internally + * normalises every command id to colon-separated regardless of the + * configured `topicSeparator`. + */ + readonly commandIDs: readonly string[]; + readonly pjson: { + readonly oclif?: { + readonly topics?: Readonly< + Record + >; + }; + }; +} + +/** + * Envelope options shape returned by {@link buildUnknownCommandEnvelope}. + * Matches the input contract of `emitCliError` minus the stream/exit hooks + * the helper does not own. + */ +export type UnknownCommandEnvelopeOptions = Omit; + +export interface BuildUnknownCommandEnvelopeInput { + readonly config: OclifLikeConfig; + /** + * Oclif-normalised command id with `:` separators (e.g. + * `'pm:reaad-work-item'`). This is the `id` field the + * `command_not_found` hook receives. + */ + readonly id: string; + /** + * Optional positional argv slice oclif passes alongside `id`. Reserved + * for future use; the helper does not consume it today. + */ + readonly argv?: readonly string[]; +} + +const HINT_SEPARATOR = ' '; + +/** Extract the deduped, sorted union of topic names available to the CLI. */ +function collectTopics(config: OclifLikeConfig): string[] { + const set = new Set(); + for (const id of config.commandIDs) { + const topic = id.split(':')[0]; + if (topic) set.add(topic); + } + const explicit = config.pjson.oclif?.topics; + if (explicit) { + for (const [name, value] of Object.entries(explicit)) { + if (value?.hidden) continue; + set.add(name); + } + } + return [...set].sort(); +} + +/** Extract the deduped, sorted list of subcommand names under `topic`. */ +function collectSubcommandsForTopic(config: OclifLikeConfig, topic: string): string[] { + const prefix = `${topic}:`; + const set = new Set(); + for (const id of config.commandIDs) { + if (!id.startsWith(prefix)) continue; + const rest = id.slice(prefix.length); + if (!rest) continue; + const sub = rest.split(':')[0]; + if (sub) set.add(sub); + } + return [...set].sort(); +} + +/** Join `[bin, ...segments]` with the cascade-tools topicSeparator. */ +function formatCommand(bin: string, segments: readonly string[]): string { + return [bin, ...segments].join(HINT_SEPARATOR); +} + +/** Format the comma-separated `expected` field shown to the agent. */ +function formatExpected(candidates: readonly string[]): string { + return candidates.join(', '); +} + +/** + * Return the closest VIABLE candidate to `unknown` — viable meaning the + * candidate passes both the distance budget ({@link MAX_SUGGESTION_DISTANCE}) + * and the ratio budget ({@link MAX_SUGGESTION_RATIO}) defined by the shared + * scorer at `gadgets/shared/cli/suggestions.ts` (MNG-1440). + * + * The shared `suggestClosest` picks the first equidistant candidate by + * iteration order and THEN validates the budget. That contract suits flag + * suggestions (homogeneous lengths, alias→canonical fan-in) but misfires + * for command topics with mixed lengths: e.g. `sm` typo ties `pm` and + * `scm` at distance 1; `pm` iterates first then fails the 0.4 ratio gate + * (1 / max(2,2) = 0.5), suppressing the viable `scm` suggestion. + * + * This local variant evaluates eligibility on every candidate and picks + * the closest one that survives. Ties are still broken by input order + * among viable candidates, matching the shared scorer's documented + * stability guarantee. + */ +function suggestClosestViable(unknown: string, candidates: readonly string[]): string | null { + let best: { name: string; dist: number } | null = null; + for (const candidate of candidates) { + const d = distance(unknown, candidate); + if (d > MAX_SUGGESTION_DISTANCE) continue; + const target = Math.max(unknown.length, candidate.length); + if (target > 0 && d / target > MAX_SUGGESTION_RATIO) continue; + if (best === null || d < best.dist) { + best = { name: candidate, dist: d }; + } + } + return best?.name ?? null; +} + +/** + * Build the `unknown-command` envelope options for an unknown `cascade-tools` + * invocation. Two cases: + * + * 1. **Unknown top-level topic** (e.g. `sm get-pr-diff`) — compare the + * first segment against the union topic set; if a close match is found, + * hint with the corrected topic and the user's preserved trailing + * segments. + * 2. **Known topic, unknown subcommand** (e.g. `pm reaad-work-item`) — + * compare the trailing segment against the topic's subcommand set; if a + * close match is found, hint with ` `. + * + * When no candidate is within the suggestion budget (distance `<= 2`, + * ratio `<= 0.4`), the envelope omits the `hint` field but still carries + * the `expected` candidate list so the agent can self-correct from a + * concrete enumeration. + */ +export function buildUnknownCommandEnvelope( + input: BuildUnknownCommandEnvelopeInput, +): UnknownCommandEnvelopeOptions { + const { config, id } = input; + const segments = id.split(':').filter((seg) => seg.length > 0); + const topicSegment = segments[0] ?? ''; + const rest = segments.slice(1); + const got = segments.join(HINT_SEPARATOR); + + const topics = collectTopics(config); + + // Case A: unknown top-level topic. Suggest closest topic, preserve rest. + if (!topics.includes(topicSegment)) { + const closest = suggestClosestViable(topicSegment, topics); + const envelope: UnknownCommandEnvelopeOptions = { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(topics), + }; + if (closest) { + envelope.hint = `did you mean '${formatCommand(config.bin, [closest, ...rest])}'?`; + } + return envelope; + } + + // Case B: known topic, no subcommand. Surface the topic's subcommands so + // the agent has a runnable enumeration. oclif normally routes bare-topic + // invocations to topic-help before command_not_found fires, but the + // helper handles this case defensively for direct callers. + const subcommands = collectSubcommandsForTopic(config, topicSegment); + if (rest.length === 0) { + return { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(subcommands), + }; + } + + // Case C: known topic, unknown subcommand. Compare the trailing segment + // only (cascade-tools commands are flat: `:` with no nested + // topics). Preserve any further trailing segments verbatim in the hint + // for forward compatibility. + const unknownSub = rest[0]; + const trailing = rest.slice(1); + const closest = suggestClosestViable(unknownSub, subcommands); + const envelope: UnknownCommandEnvelopeOptions = { + type: 'unknown-command', + message: `Unknown command '${formatCommand(config.bin, segments)}'`, + got, + expected: formatExpected(subcommands), + }; + if (closest) { + envelope.hint = `did you mean '${formatCommand(config.bin, [topicSegment, closest, ...trailing])}'?`; + } + return envelope; +} diff --git a/src/gadgets/shared/errorEnvelope.ts b/src/gadgets/shared/errorEnvelope.ts index df1a76630..4e098f2a4 100644 --- a/src/gadgets/shared/errorEnvelope.ts +++ b/src/gadgets/shared/errorEnvelope.ts @@ -2,7 +2,8 @@ * Shared cascade-tools CLI error envelope (spec 014). * * Every cascade-tools failure — flag-parse, JSON-parse, missing-required, - * enum-mismatch, unknown-flag, auth, runtime — emits through {@link emitCliError}: + * enum-mismatch, unknown-flag, unknown-command, auth, runtime — emits through + * {@link emitCliError}: * * - Structured JSON on stdout: `{"success":false,"error":}` so agents * parsing CLI output see one stable surface. @@ -17,6 +18,15 @@ /** * Classification of a cascade-tools failure. Agents may branch on this. + * + * `unknown-command` is emitted when the user invokes a topic or subcommand + * that is not registered (e.g. `cascade-tools sm get-pr-diff` or + * `cascade-tools pm reaad-work-item`). The envelope's `expected` field + * carries the comma-separated list of valid candidates the typo was + * compared against; `hint` carries the runnable suggestion when one falls + * inside the Levenshtein-distance budget. See + * `src/cli/_shared/commandSuggestions.ts` for the pure helper that builds + * the envelope options. */ export type CliErrorType = | 'flag-parse' @@ -24,6 +34,7 @@ export type CliErrorType = | 'missing-required' | 'enum-mismatch' | 'unknown-flag' + | 'unknown-command' | 'auth' | 'runtime'; diff --git a/tests/unit/cli/command-suggestions.test.ts b/tests/unit/cli/command-suggestions.test.ts new file mode 100644 index 000000000..7cc3b49be --- /dev/null +++ b/tests/unit/cli/command-suggestions.test.ts @@ -0,0 +1,224 @@ +/** + * Pin the pure unknown-command suggestion helper (MNG-1441). + * + * The helper is the testable seam that decides which suggestion (if any) is + * surfaced when a `cascade-tools` agent typos a topic or subcommand. These + * tests guarantee: + * + * - Topic typos suggest the closest topic and preserve the user's trailing + * segments (`sm get-pr-diff` → `scm get-pr-diff`). + * - Subcommand typos under a known topic suggest the closest subcommand + * (`pm reaad-work-item` → `pm read-work-item`). + * - Far-away typos drop the `hint` field but still surface the candidate + * list via `expected`, so the agent has a concrete enumeration to + * self-correct from. + * - The candidate set never leaks topics that are not loaded by + * `cascade-tools` (the dashboard topic is excluded automatically because + * its glob is filtered out in `bin/cascade-tools.js`). + * + * The helper does NOT install oclif's `command_not_found` hook — wiring is + * out of scope for MNG-1441. These tests pin decisions only. + */ + +import { describe, expect, it } from 'vitest'; + +import { + buildUnknownCommandEnvelope, + type OclifLikeConfig, +} from '../../../src/cli/_shared/commandSuggestions.js'; + +/** + * Construct a minimal oclif-like config that mirrors the shape + * `bin/cascade-tools.js` produces (four topics, flat `:` IDs, + * no dashboard surface). + */ +function makeConfig(overrides: Partial = {}): OclifLikeConfig { + return { + bin: 'cascade-tools', + commandIDs: [ + 'pm:read-work-item', + 'pm:post-comment', + 'pm:update-work-item', + 'pm:add-checklist', + 'pm:update-checklist-item', + 'scm:create-pr', + 'scm:get-pr-diff', + 'scm:post-pr-comment', + 'scm:create-pr-review', + 'alerting:get-alerting-event', + 'alerting:get-alerting-issue', + 'session:finish', + ], + pjson: { + oclif: { + topics: { + pm: { description: 'PM topic' }, + scm: { description: 'SCM topic' }, + alerting: { description: 'Alerting topic' }, + session: { description: 'Session topic' }, + }, + }, + }, + ...overrides, + }; +} + +describe('buildUnknownCommandEnvelope', () => { + it('marks the envelope as unknown-command with the typed input in `got`', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:reaad-work-item', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.got).toBe('pm reaad-work-item'); + // No `flag` field for command-level failures. + expect(envelope.flag).toBeUndefined(); + // Message renders the runnable form so humans reading stderr see what + // they typed in CLI shape, not oclif's colon-separated id. + expect(envelope.message).toBe("Unknown command 'cascade-tools pm reaad-work-item'"); + }); + + it('suggests the closest topic for a top-level typo (`sm get-pr-diff` → `scm get-pr-diff`)', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'sm:get-pr-diff', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools scm get-pr-diff'?"); + // Topic-typo envelopes list topics in `expected`, not subcommands — + // the unknown segment is the topic. + expect(envelope.expected).toBe('alerting, pm, scm, session'); + }); + + it('suggests the closest subcommand under a known topic (`pm reaad-work-item` → `pm read-work-item`)', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:reaad-work-item', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools pm read-work-item'?"); + // Subcommand-typo envelopes list the topic's subcommands so the agent + // has a concrete enumeration even when the hint is not exact. + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.expected).toContain('post-comment'); + // Subcommand expected MUST NOT leak other topics' subcommands. + expect(envelope.expected).not.toContain('create-pr'); + }); + + it('omits `hint` when the typo is too far from any candidate', () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm:totallyunrelated', + }); + expect(envelope.hint).toBeUndefined(); + // `expected` is still populated so the agent has a recovery path. + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.message).toBe("Unknown command 'cascade-tools pm totallyunrelated'"); + }); + + it('omits `hint` for far-away top-level topic typos', () => { + // 'zzzzzzzz' is well beyond the distance budget for any registered + // cascade-tools topic. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'zzzzzzzz:something', + }); + expect(envelope.hint).toBeUndefined(); + expect(envelope.expected).toBe('alerting, pm, scm, session'); + }); + + it("surfaces a useful `expected` field for subcommand typos (the topic's actual command list)", () => { + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'scm:get-pr-diffx', + }); + // Comma-separated, deterministic (sorted) list matches the + // enum-mismatch shape agents already parse from `parseErrors.ts`. + expect(envelope.expected).toBe('create-pr, create-pr-review, get-pr-diff, post-pr-comment'); + }); + + it('excludes dashboard topics when they are not loaded by cascade-tools', () => { + // Mirror `bin/cascade-tools.js`: dashboard glob is excluded from + // command discovery, and `pjson.oclif.topics` does not declare a + // `dashboard` entry. Topic candidates must reflect that. + const config = makeConfig(); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'dashbord:projects', + }); + // The candidate list does not include `dashboard`, so even if + // `dashbord` were within edit distance, it could not be suggested. + expect(envelope.expected.split(', ')).not.toContain('dashboard'); + expect(envelope.hint).toBeUndefined(); + }); + + it('does include a topic derived from commandIDs even when not in pjson.oclif.topics', () => { + // Topics are the union of commandIDs' first segments + explicit + // pjson topics. A plugin-contributed topic that didn't make it into + // `pjson.oclif.topics` is still a valid candidate. + const config = makeConfig({ + commandIDs: [...makeConfig().commandIDs, 'plugin:do-thing'], + }); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'plugn:do-thing', // distance 1 from 'plugin' + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools plugin do-thing'?"); + expect(envelope.expected.split(', ')).toContain('plugin'); + }); + + it('skips hidden topics from `pjson.oclif.topics` when building candidates', () => { + // Hidden topics never appear in `cascade-tools --help` and should + // not be suggested either. Without this filter, a typo that + // resembles a hidden topic would surface confusing guidance. + const config = makeConfig({ + commandIDs: ['pm:read-work-item'], + pjson: { + oclif: { + topics: { + pm: { description: 'PM' }, + internal: { description: 'Internal', hidden: true }, + }, + }, + }, + }); + const envelope = buildUnknownCommandEnvelope({ + config, + id: 'internel:thing', // distance 1 from 'internal' + }); + expect(envelope.expected.split(', ')).not.toContain('internal'); + expect(envelope.hint).toBeUndefined(); + }); + + it("preserves the user's trailing positional segments when suggesting a topic", () => { + // The trailing segment(s) are echoed verbatim in the hint so the + // agent can immediately retry without retyping. Helpful when the + // trailing form happens to be valid under the corrected topic. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'sm:create-pr', + }); + expect(envelope.hint).toBe("did you mean 'cascade-tools scm create-pr'?"); + }); + + it('handles bare topic invocations defensively (no subcommand to suggest)', () => { + // oclif normally routes bare-topic input to topic-help before + // command_not_found fires, but direct callers may still hit this + // path. The envelope should surface the subcommand enumeration. + const envelope = buildUnknownCommandEnvelope({ + config: makeConfig(), + id: 'pm', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.expected).toContain('read-work-item'); + expect(envelope.hint).toBeUndefined(); + }); + + it('produces no candidates and no hint when the config has no loaded commands or explicit topics', () => { + const envelope = buildUnknownCommandEnvelope({ + config: { bin: 'cascade-tools', commandIDs: [], pjson: { oclif: {} } }, + id: 'pm:read-work-item', + }); + expect(envelope.type).toBe('unknown-command'); + expect(envelope.expected).toBe(''); + expect(envelope.hint).toBeUndefined(); + }); +}); From 945107cce6f55ef682da4696a7f848d1acde7cd1 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 2 Jun 2026 19:41:15 +0200 Subject: [PATCH 09/25] feat(cli): unknown-command hook for cascade-tools (MNG-1442) (#1395) Co-authored-by: Cascade Bot --- CHANGELOG.md | 4 + bin/cascade-tools.js | 17 ++ docs/architecture/07-gadgets.md | 12 ++ src/cli/_shared/command-not-found-hook.ts | 74 +++++++ src/gadgets/README.md | 33 +++- .../cascade-tools-command-suggestions.test.ts | 187 ++++++++++++++++++ 6 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 src/cli/_shared/command-not-found-hook.ts create mode 100644 tests/unit/cli/cascade-tools-command-suggestions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8facad0d..449b92beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable user-visible changes to CASCADE are documented here. The format is l ## Unreleased +### Added + +- **`cascade-tools` now suggests the closest command when an agent typos a topic or subcommand** ([MNG-1442](https://linear.app/issue/MNG-1442)). `bin/cascade-tools.js` registers an oclif `command_not_found` hook that turns command typos into the same structured spec-014 envelope every other CLI failure emits: JSON on stdout, a one-line prose summary on stderr, and a runnable `did you mean` hint when within the shared Levenshtein budget (MNG-1440). Unknown top-level topics (`cascade-tools sm get-pr-diff`) surface a topic enumeration in `expected` and a hint that preserves the user's trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). Known-topic / unknown-subcommand typos (`cascade-tools pm reaad-work-item`) surface the topic's subcommand enumeration and a corrected hint (`did you mean 'cascade-tools pm read-work-item'?`). Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery path. Exit code is **`2`** for `unknown-command` — preserved from oclif's historical `command_not_found` default and distinct from every other envelope's exit code `1`; existing exit-code consumers see no change. The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` so oclif's command-discovery glob in `bin/cascade-tools.js` excludes it. It is wired through `pjson.oclif.hooks` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path in the entrypoint. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. Existing `unknown-flag` handling from `createCLICommand()` is untouched. Closes [MNG-1442](https://linear.app/mongrel/issue/MNG-1442). + ### Fixed - **`cascade-tools` multiline text and large diff I/O are now hardened against shell-quoting footguns and stdout truncation** ([MNG-1059](https://linear.app/issue/MNG-1059)). The shared CLI factory at `src/gadgets/shared/cli/params.ts` now rejects invocations that pass `--*-file -` for two or more file-input flags (e.g. `--body-file - --comments-file -`) before any `readFileSync(0, ...)` call — stdin (fd 0) can only be drained once per process, and the previous behavior silently truncated one of the two agent payloads. The rejection emits a structured `flag-parse` error envelope (`error.flag: "body-file,comments-file"`, `hint: "Pass at most one --*-file -; for the others, write the payload to a temp file and pass ---file ."`) so agents can self-correct on the next attempt. Direct file paths remain pairwise-compatible — `--body-file - --comments-file /tmp/comments.json` and `--body-file /tmp/body.md --comments-file -` both work as before. The native-tool system prompt now renders a "cascade-tools shell-safety rules" section that documents the one-stdin-consumer invariant and provides safe heredoc / temp-file patterns for one and two payloads. The prompt renderer also suppresses inline `--body '...'` / `--text '...'` examples whose content contains backticks, code fences, `$(...)`, or newlines when a file-input companion is declared, redirecting the agent at the safer `--*-file ` form instead. File-input flag descriptions for `--body-file`, `--text-file`, `--description-file`, `--details-file`, and `--comments-file` explicitly call out markdown / multiline / backticks. Closes [MNG-908](https://linear.app/mongrel/issue/MNG-908), [MNG-910](https://linear.app/mongrel/issue/MNG-910), [MNG-917](https://linear.app/mongrel/issue/MNG-917), [MNG-1046](https://linear.app/mongrel/issue/MNG-1046). diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index d822051d4..a9286f91a 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -72,6 +72,23 @@ pjson.oclif = { globPatterns: ['**/*.js', '!**/dashboard/**', '!**/_shared/**', '!base.js', '!bootstrap.js'], }, topicSeparator: ' ', + // `command_not_found` hook turns command typos into the structured + // spec-014 envelope (JSON on stdout, prose on stderr, runnable + // `did you mean` hint when within budget) instead of oclif's bare + // `command not found` message. Exit code stays 2 (oclif's + // historical default for command_not_found) via an explicit exit + // delegate inside the hook — see + // `src/cli/_shared/command-not-found-hook.ts` for the full rationale. + // + // The hook is wired via oclif's `pjson.oclif.hooks` so it is loaded + // lazily by `loadWithData` when the hook actually fires — *not* + // statically required at entrypoint time. This preserves the friendly + // `dist/cli/bootstrap.js` missing path above: if the build is absent, + // the bootstrap import throws ERR_MODULE_NOT_FOUND first and exits 1 + // with the explainer, before this hook is ever resolved. + hooks: { + command_not_found: './dist/cli/_shared/command-not-found-hook.js', + }, // Explicit topic summaries. Without this block oclif borrows each topic's // description from its FIRST command (see node_modules/@oclif/core // /lib/config/config.js — the line `this._topics.set(name, { description: diff --git a/docs/architecture/07-gadgets.md b/docs/architecture/07-gadgets.md index 244ee90a5..1a6f637e9 100644 --- a/docs/architecture/07-gadgets.md +++ b/docs/architecture/07-gadgets.md @@ -138,6 +138,7 @@ The `cascade-tools` binary uses a separate oclif config (`bin/cascade-tools.js`) |--------|------| | `commandNames.ts` | Command namespace/name derivation shared by the CLI factory and manifest generator | | `examples.ts` | Tool example lookup, shell quoting, oclif example rendering, and JSON expected-shape hints | +| `suggestions.ts` | Shared Levenshtein scorer for flag and command typo suggestions (MNG-1440) | | `flags.ts` | oclif flag construction and flag metadata collection | | `booleanArgv.ts` | Boolean value-form normalization before oclif parsing | | `parseErrors.ts` | oclif parse-error classification and unknown-flag suggestions | @@ -148,6 +149,17 @@ New domain commands should not add branches in these helpers. They declare behav Core functions passed to `createCLICommand()` own domain work only. On fatal runtime/API/provider failures they throw, and the shared factory converts that exception into the structured `{"success":false,"error":{"type":"runtime","message":"..."}}` stdout envelope plus exit code 1. A returned value is always serialized as successful `data`, so gadgets must not return sentinel error strings such as `Error reading work item: ...` for fatal failures. Non-fatal command states that are part of the contract, such as guarded PM move no-ops or friction retry queueing, remain successful returns. +### Unknown-command typo suggestions (MNG-1442) + +`bin/cascade-tools.js` registers an oclif `command_not_found` hook so command typos emit the same structured envelope every other CLI failure does (spec 014): JSON on stdout, prose on stderr, runnable `did you mean` hint when within the shared Levenshtein budget. Two cases the hook covers: + +- **Unknown top-level topic** (`cascade-tools sm get-pr-diff`) — `expected` lists topics, `hint` preserves trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). +- **Known topic, unknown subcommand** (`cascade-tools pm reaad-work-item`) — `expected` lists the topic's subcommands, `hint` runs the corrected form (`did you mean 'cascade-tools pm read-work-item'?`). + +Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery enumeration. Exit code is **`2`** for `unknown-command` — oclif's historical `command_not_found` default — distinct from every other envelope's exit code `1`. + +The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` because `bin/cascade-tools.js`'s oclif command-discovery glob excludes `**/_shared/**`. The entrypoint wires it via `pjson.oclif.hooks.command_not_found` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; the hook is a thin wrapper that forwards `{config, id, argv}` into the helper and routes the envelope through `emitCliError` with an explicit exit-code-2 delegate. Candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. + ### Mutation result contract (MNG-1422 → MNG-1428) Every PM mutation core and the SCM PR comment/reply/update/review mutation cores covered by MNG-1428 return structured objects, never prose. The CLI factory serialises those objects verbatim into `{"success":true,"data":{...}}`, so consumers (downstream agents, sidecars, review/respond workflows) can read structured keys directly. diff --git a/src/cli/_shared/command-not-found-hook.ts b/src/cli/_shared/command-not-found-hook.ts new file mode 100644 index 000000000..91978ba8c --- /dev/null +++ b/src/cli/_shared/command-not-found-hook.ts @@ -0,0 +1,74 @@ +/** + * oclif `command_not_found` hook for `cascade-tools` (MNG-1442). + * + * When an agent invokes a topic or subcommand that doesn't exist (e.g. + * `cascade-tools sm get-pr-diff` or `cascade-tools pm reaad-work-item`), + * oclif's default behavior is to throw `command not found`, which the + * binary entrypoint terminates with exit code 2. That fallback has no + * structure, no suggestion, and no candidate list — pre-spec-014 ergonomics + * the rest of `cascade-tools` has moved past. + * + * This hook turns command typos into the same structured envelope every + * other `cascade-tools` failure emits (spec 014): JSON on stdout, a one-line + * prose summary on stderr, and a runnable `did you mean` hint when the typo + * is within the Levenshtein budget. Exit code `2` is preserved — that is + * oclif's documented `command_not_found` default and existing consumers + * (including the `bin/cascade-tools.js` catch block) rely on it. + * + * **Hook placement.** This file lives under `src/cli/_shared/` because the + * oclif command-discovery glob in `bin/cascade-tools.js` explicitly excludes + * `**\/_shared/**`. Without that exclusion, a default-exported function in + * a discoverable directory would be loaded as a fake top-level command and + * shadow the hook contract — see `bin/cascade-tools.js` for the glob. + * + * **No static import in the entrypoint.** The hook is wired through + * `pjson.oclif.hooks.command_not_found`, which oclif loads dynamically via + * `loadWithData` only when the hook actually fires. `bin/cascade-tools.js` + * therefore keeps its existing friendly `dist/` missing path intact — if + * `dist/cli/bootstrap.js` is absent, the entrypoint emits the friendly + * stderr explainer and exits 1 before this module is ever resolved. + * + * **Envelope-building delegation.** The pure suggestion logic lives in + * `./commandSuggestions.ts` (MNG-1441) so it can be unit-tested without + * booting oclif or installing this hook. This module is a thin wrapper that + * forwards `{config, id, argv}` into the helper and routes the returned + * envelope options through `emitCliError` with the documented exit-code-2 + * override. + */ + +import type { Hook } from '@oclif/core'; + +import { emitCliError } from '../../gadgets/shared/errorEnvelope.js'; +import { buildUnknownCommandEnvelope, type OclifLikeConfig } from './commandSuggestions.js'; + +/** + * Explicit exit delegate that ignores the input code and exits with `2`. + * + * `emitCliError` always passes `1` to its exit delegate — the spec-014 + * default for every other CLI failure type (`flag-parse`, `runtime`, etc.). + * For `unknown-command` we deliberately diverge: `2` is oclif's + * `command_not_found` default and the cascade-tools entrypoint already + * forwards it through `process.exit(err.oclif.exit)` for unknown commands. + * Keeping the same exit code on the structured-envelope path avoids + * regressing any tooling that branches on the historical 2 vs other codes. + */ +const exitWithCode2: (code: number) => never = () => process.exit(2); + +const commandNotFoundHook: Hook<'command_not_found'> = async (opts) => { + const envelopeOpts = buildUnknownCommandEnvelope({ + // oclif's full Config carries dozens of fields the helper does not + // consume. `OclifLikeConfig` declares the narrow subset (bin, + // commandIDs, pjson.oclif.topics) the helper needs, so a structural + // cast through `unknown` is safe and avoids dragging the full Config + // dependency through the helper module. + config: opts.config as unknown as OclifLikeConfig, + id: opts.id, + argv: opts.argv, + }); + emitCliError({ + ...envelopeOpts, + exit: exitWithCode2, + }); +}; + +export default commandNotFoundHook; diff --git a/src/gadgets/README.md b/src/gadgets/README.md index 1d947dc37..2b1351366 100644 --- a/src/gadgets/README.md +++ b/src/gadgets/README.md @@ -116,11 +116,11 @@ examples: [ ## The error envelope -Every cascade-tools failure — flag parse, JSON parse, missing-required, enum-mismatch, unknown-flag, auth, runtime — emits through the shared `emitCliError` helper: +Every cascade-tools failure — flag parse, JSON parse, missing-required, enum-mismatch, unknown-flag, unknown-command, auth, runtime — emits through the shared `emitCliError` helper: - **Structured JSON on stdout** (`{ "success": false, "error": {...} }`) so agents parse a single stable surface. - **One-line prose summary on stderr** so humans running the CLI directly get a readable error without piping through `jq`. -- **Exit code 1.** +- **Exit code 1** for every type except `unknown-command`, which preserves oclif's historical `command_not_found` exit code **2** (see "Mistyped commands" below). The envelope shape is part of the cascade-tools contract. Renaming fields is a breaking change — agents rely on `error.type` / `error.flag` / `error.hint` to self-correct on the next attempt. @@ -128,12 +128,12 @@ Envelope fields: | field | when populated | |---|---| -| `type` | always; one of `flag-parse` / `json-parse` / `missing-required` / `enum-mismatch` / `unknown-flag` / `auth` / `runtime` | +| `type` | always; one of `flag-parse` / `json-parse` / `missing-required` / `enum-mismatch` / `unknown-flag` / `unknown-command` / `auth` / `runtime` | | `flag` | for flag-scoped failures | | `message` | always; human-readable | -| `got` | the offending input, truncated to ~80 chars | -| `expected` | shape fragment (from `example` when available, else `describe`) | -| `hint` | an action the agent can take (e.g. `did you mean --comments?`, `use --comments-file `) | +| `got` | the offending input, truncated to ~80 chars (for `unknown-command`, the typed command in space-separated form, e.g. `pm reaad-work-item`) | +| `expected` | shape fragment (from `example` when available, else `describe`; for `unknown-command`, the comma-separated candidate list — topics for top-level typos, subcommands for known-topic typos) | +| `hint` | an action the agent can take (e.g. `did you mean --comments?`, `use --comments-file `, `did you mean 'cascade-tools scm get-pr-diff'?`) | | `example` | runnable invocation, when known | You do not call `emitCliError` directly. The shared factory routes every failure through it automatically — your job is to make the declarative metadata (describe text, examples, aliases, file alternatives) rich enough that the auto-generated envelope is actually useful. @@ -255,7 +255,26 @@ If you find yourself opening one of those files, stop — the right fix is almos The factory intercepts oclif's `NonExistentFlagsError`, runs a Levenshtein match against every declared canonical flag name + alias, and surfaces the closest canonical name as `error.hint`. No gadget work required — just declare your flags truthfully. -Two tuning constants live in `src/gadgets/shared/cli/parseErrors.ts`: `MAX_FLAG_SUGGESTION_DISTANCE` (default 2) and `MAX_FLAG_SUGGESTION_RATIO` (default 0.4). Wildly-off mistypes get no suggestion rather than a misleading one. +Two tuning constants live in `src/gadgets/shared/cli/suggestions.ts` (MNG-1440 shared helper): `MAX_SUGGESTION_DISTANCE` (default 2) and `MAX_SUGGESTION_RATIO` (default 0.4). Wildly-off mistypes get no suggestion rather than a misleading one. The same constants gate the command-typo path described below — flag and command suggestions stay calibrated against one shared budget. + +--- + +## Mistyped commands → "did you mean" (MNG-1442) + +`cascade-tools` registers an oclif `command_not_found` hook that turns typoed topics or subcommands into the same structured envelope every other failure emits — instead of oclif's bare `command not found` message. Two cases: + +- **Unknown top-level topic** (e.g. `cascade-tools sm get-pr-diff`) — `expected` carries the topic enumeration, and `hint` carries the closest viable topic with the user's trailing segments preserved (`did you mean 'cascade-tools scm get-pr-diff'?`). +- **Known topic, unknown subcommand** (e.g. `cascade-tools pm reaad-work-item`) — `expected` carries the topic's subcommand enumeration, and `hint` carries the closest viable subcommand (`did you mean 'cascade-tools pm read-work-item'?`). + +Far-away typos (beyond the shared distance / ratio budget) drop the `hint` field but still surface `expected` so the agent has a concrete recovery path. The exit code is **`2`** (oclif's historical `command_not_found` default) — distinct from every other envelope's exit code `1`. Existing exit-code consumers, including the `bin/cascade-tools.js` catch block, see no change. + +**Hook placement.** The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` because the oclif command-discovery glob in `bin/cascade-tools.js` excludes `**/_shared/**` — without that exclusion, a default-exported function in a discoverable directory would be loaded as a fake top-level command. The hook is wired via `pjson.oclif.hooks.command_not_found` so oclif loads it dynamically with `loadWithData` only when the hook actually fires; the entrypoint never statically imports it, which preserves the existing friendly `dist/cli/bootstrap.js` missing path. + +**Pure envelope builder.** The suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441), which is side-effect free and unit-tested directly without booting oclif. The hook is a thin oclif-side wrapper that forwards `{config, id, argv}` into the helper and routes the envelope options through `emitCliError` with an explicit exit-code-2 delegate. + +**Suggestion-source contract.** Candidates come strictly from the loaded oclif config (`config.commandIDs` plus `pjson.oclif.topics`, skipping `hidden` topics). The `cascade-tools` binary uses a separate oclif config that excludes the dashboard topic from its discovery glob, so dashboard commands are never suggested for `cascade-tools` typos even if they would be within edit distance. + +No gadget work required — declaring your command and topics in the standard oclif config is everything. --- diff --git a/tests/unit/cli/cascade-tools-command-suggestions.test.ts b/tests/unit/cli/cascade-tools-command-suggestions.test.ts new file mode 100644 index 000000000..f5d371085 --- /dev/null +++ b/tests/unit/cli/cascade-tools-command-suggestions.test.ts @@ -0,0 +1,187 @@ +/** + * Pin the spawn-level `command_not_found` behavior of `cascade-tools` (MNG-1442). + * + * The pure suggestion helper is covered by + * `tests/unit/cli/command-suggestions.test.ts`. This file covers the WIRING: + * + * - The oclif `command_not_found` hook is actually installed for the + * `cascade-tools` binary (not just the helper). + * - A typoed topic (`sm get-pr-diff`) and a typoed subcommand + * (`pm reaad-work-item`) each surface a structured JSON envelope on + * stdout, a one-line prose summary on stderr, and exit code `2`. + * - A typo that is far from every candidate drops the `hint` field but + * still surfaces an `expected` candidate enumeration so the agent has a + * concrete recovery path. + * - The existing `unknown-flag` handling for valid commands keeps emitting + * `unknown-flag` from `createCLICommand()` — unknown-command logic must + * not regress the flag-suggestion path. + * + * Tests skip with a clear message when the repo has not been built (the + * binary at `bin/cascade-tools.js` requires `dist/cli/bootstrap.js` before + * oclif can route any command, including the not-found hook). This mirrors + * `tests/unit/cli/cascade-tools-help.test.ts`. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = resolve(__dirname, '../../..'); +const BIN = resolve(REPO_ROOT, 'bin/cascade-tools.js'); +const DIST = resolve(REPO_ROOT, 'dist/cli/bootstrap.js'); +const HOOK_DIST = resolve(REPO_ROOT, 'dist/cli/_shared/command-not-found-hook.js'); + +interface SpawnResult { + stdout: string; + stderr: string; + code: number | null; +} + +function runCascadeTools(args: string[]): SpawnResult { + // Strip NODE_ENV — vitest sets it to 'test' which trips the integration + // entrypoint loaded by `bin/cascade-tools.js` and exits 2 with no + // diagnostic. Unrelated to the unknown-command behavior under test. + const env = { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }; + delete env.NODE_ENV; + const result = spawnSync('node', [BIN, ...args], { + cwd: REPO_ROOT, + encoding: 'utf-8', + env, + timeout: 30_000, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status }; +} + +interface UnknownCommandEnvelope { + success: false; + error: { + type: 'unknown-command'; + message: string; + got?: string; + expected?: string; + hint?: string; + }; +} + +interface UnknownFlagEnvelope { + success: false; + error: { + type: 'unknown-flag'; + flag?: string; + message: string; + }; +} + +function parseEnvelope(stdout: string): T { + const trimmed = stdout.trim(); + // Each spec-014 envelope is a single JSON line — but defensively parse the + // last non-empty line in case oclif ever prepends warnings. + const lastLine = trimmed.split(/\n+/).pop() ?? ''; + return JSON.parse(lastLine) as T; +} + +describe('cascade-tools command_not_found hook (MNG-1442)', () => { + const built = existsSync(DIST) && existsSync(HOOK_DIST); + + it.skipIf(!built)( + 'unknown topic `sm get-pr-diff` emits unknown-command JSON envelope on stdout with a `scm get-pr-diff` hint', + () => { + const result = runCascadeTools(['sm', 'get-pr-diff']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBe("did you mean 'cascade-tools scm get-pr-diff'?"); + expect(env.error.got).toBe('sm get-pr-diff'); + // Topic-level enumeration so the agent sees the four candidates even + // when the hint already nails it. + expect((env.error.expected ?? '').split(', ')).toEqual( + expect.arrayContaining(['alerting', 'pm', 'scm', 'session']), + ); + + // Prose summary on stderr — one line, humans-readable, no JSON. + expect(result.stderr.trim().split('\n').length).toBe(1); + expect(result.stderr).toContain('unknown-command'); + expect(result.stderr).not.toContain('{"success"'); + }, + ); + + it.skipIf(!built)( + 'unknown subcommand `pm reaad-work-item` emits unknown-command JSON with a `pm read-work-item` hint', + () => { + const result = runCascadeTools(['pm', 'reaad-work-item']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBe("did you mean 'cascade-tools pm read-work-item'?"); + expect(env.error.got).toBe('pm reaad-work-item'); + // Subcommand-level enumeration: must include sibling pm commands and + // must NOT leak other topics' subcommands (e.g. scm:create-pr). + expect(env.error.expected).toContain('read-work-item'); + expect(env.error.expected).not.toContain('create-pr'); + }, + ); + + it.skipIf(!built)( + 'far-away typos omit the `hint` field but still surface the candidate enumeration', + () => { + // `zzzzzzzz` is well beyond the distance budget for any registered + // topic. The agent should still get a runnable list of candidates. + const result = runCascadeTools(['zzzzzzzz', 'something']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBeUndefined(); + expect((env.error.expected ?? '').split(', ')).toEqual( + expect.arrayContaining(['alerting', 'pm', 'scm', 'session']), + ); + }, + ); + + it.skipIf(!built)( + 'far-away subcommand typo on a known topic also omits `hint` but lists subcommands', + () => { + // `totallyunrelated` shares almost nothing with any pm subcommand — + // confirms the noise gate applies on the subcommand path too. + const result = runCascadeTools(['pm', 'totallyunrelated']); + expect(result.code).toBe(2); + + const env = parseEnvelope(result.stdout); + expect(env.error.type).toBe('unknown-command'); + expect(env.error.hint).toBeUndefined(); + expect(env.error.expected).toContain('read-work-item'); + expect(env.error.expected).not.toContain('create-pr'); + }, + ); + + it.skipIf(!built)( + 'existing unknown-flag handling is untouched (regression net for createCLICommand())', + () => { + // `pm read-work-item` is a real command; `--unknownflag` is not. + // The unknown-flag envelope is emitted by `createCLICommand()`'s + // parse-error classifier, not by the command_not_found hook. This + // test pins that the new hook does NOT shadow that path. + const result = runCascadeTools([ + 'pm', + 'read-work-item', + '--workItemId', + 'foo', + '--unknownflag', + 'bar', + ]); + // `unknown-flag` exits with code 1 (the spec-014 default for every + // non-command envelope); a successful exit (0) or `unknown-command` + // (2) would each be a regression. + expect(result.code).toBe(1); + + const env = parseEnvelope(result.stdout); + expect(env.success).toBe(false); + expect(env.error.type).toBe('unknown-flag'); + }, + ); +}); From fa916e6b62f47cfa558f7e0fa19b0b05e5b96ae1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:08:11 +0200 Subject: [PATCH 10/25] chore(deps): bump hono from 4.12.18 to 4.12.23 (#1396) Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.23 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2df82b87b..b5bc31ae4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.14", + "hono": "^4.12.23", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^16.0.4", @@ -7431,9 +7431,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 843cdbc4f..61abfe634 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.14", + "hono": "^4.12.23", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^16.0.4", From 1190a070b7f480a0c1a21335da9d11165cb32358 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:08:15 +0200 Subject: [PATCH 11/25] chore(deps): bump @grpc/grpc-js from 1.14.3 to 1.14.4 (#1398) Bumps [@grpc/grpc-js](https://github.com/grpc/grpc-node) from 1.14.3 to 1.14.4. - [Release notes](https://github.com/grpc/grpc-node/releases) - [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.3...@grpc/grpc-js@1.14.4) --- updated-dependencies: - dependency-name: "@grpc/grpc-js" dependency-version: 1.14.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b5bc31ae4..0f8d7d6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2090,7 +2090,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.3", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", From bbb4088649e3f0e9bd91630329720fa9f3a48020 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:08:20 +0200 Subject: [PATCH 12/25] chore(deps): bump ws from 8.19.0 to 8.21.0 (#1385) Bumps [ws](https://github.com/websockets/ws) from 8.19.0 to 8.21.0. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.19.0...8.21.0) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f8d7d6d3..9f581f3c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11035,9 +11035,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" From 04e8ba5621efabb35f30a9810a1040d37d47d997 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:08:24 +0200 Subject: [PATCH 13/25] chore(deps): bump qs from 6.15.0 to 6.15.2 (#1384) Bumps [qs](https://github.com/ljharb/qs) from 6.15.0 to 6.15.2. - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.0...v6.15.2) --- updated-dependencies: - dependency-name: qs dependency-version: 6.15.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f581f3c9..50d06508e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9469,9 +9469,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From c77be1e150c8d859d220545a08294547a89cb0eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:12:25 +0200 Subject: [PATCH 14/25] chore(deps): bump shell-quote and concurrently (#1397) Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.8.4 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together. Updates `shell-quote` from 1.8.3 to 1.8.4 - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) --- updated-dependencies: - dependency-name: shell-quote dependency-version: 1.8.4 dependency-type: indirect - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 148 +++++++++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 94 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50d06508e..6c0d789eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.2.4", "commander": "^14.0.2", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", "jsdom": "^28.1.0", "lefthook": "^1.10.10", @@ -5265,118 +5265,156 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" } }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concurrently/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/concurrently/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/concurrently/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/concurrently/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/content-disposition": { @@ -9956,9 +9994,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 61abfe634..200f58db4 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.2.4", "commander": "^14.0.2", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", "jsdom": "^28.1.0", "lefthook": "^1.10.10", From 935d51d81110b827f18e4aa44725ac0c846ea79e Mon Sep 17 00:00:00 2001 From: aaight Date: Fri, 12 Jun 2026 17:14:04 +0200 Subject: [PATCH 15/25] fix(splitting): remove PR-creation references from prompt template (#1383) Co-authored-by: Cascade Bot --- src/agents/prompts/templates/splitting.eta | 4 ++-- tests/unit/agents/prompts.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/agents/prompts/templates/splitting.eta b/src/agents/prompts/templates/splitting.eta index 1058adaec..06d981c24 100644 --- a/src/agents/prompts/templates/splitting.eta +++ b/src/agents/prompts/templates/splitting.eta @@ -110,11 +110,11 @@ You are running in a cloned copy of the project repository. Before creating stor - Use "🔗 Dependencies" as the checklist name - Add each dependency as a checklist item (use <%= it.workItemNoun || 'card' %> title or URL) - Skip this checklist for foundational stories with no dependencies -6. **Post summary comment** using `PostComment` once you've confirmed PR creation: +6. **Post summary comment** using `PostComment` once all story <%= it.workItemNounPlural || 'cards' %> and their required checklists have been created: - Post a comment on the ORIGINAL <%= it.workItemNoun || 'card' %> listing all created stories - Use markdown links: `[Story Title](URL)` for each <%= it.workItemNoun || 'card' %> - See "Summary Comment Format" section below - - Use only real and confirmed PR numbers and URLs - see output from CreatePR + - Use only real story <%= it.workItemNoun || 'card' %> URLs returned by `CreateWorkItem` — never invent IDs or links 7. **Only if blocked**, post a comment using `PostComment`: - Only if there's genuine ambiguity that prevents story creation - Ask ONE specific question, then STOP diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index cc12c9654..e59c984e5 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -144,6 +144,32 @@ describe('system prompts content', () => { expect(prompt).toContain('INVEST'); }); + it('splitting prompt summary instructions reference story work items, not PR creation', () => { + const prompt = getSystemPrompt('splitting'); + // Regression for MNG-1084: the splitting workflow has no PR-creation + // capability, so the summary-comment instructions must not direct the + // agent to confirm or reference CreatePR output. + expect(prompt).not.toContain('CreatePR'); + expect(prompt).not.toContain('confirmed PR creation'); + expect(prompt).not.toContain('real and confirmed PR numbers'); + + // The replacement wording should ground the summary in confirmed + // CreateWorkItem story URLs. + expect(prompt).toContain('CreateWorkItem'); + expect(prompt).toContain('Stories Created'); + expect(prompt).toContain('[Story Title](URL)'); + expect(prompt).toContain('URLs returned by `CreateWorkItem`'); + }); + + it('splitting prompt preserves "one PR, one day" sizing heuristic', () => { + // The story-sizing language intentionally references PRs to describe + // the *target size* of a story; we must not strip that wording when + // cleaning up action-oriented PR-creation instructions. + const prompt = getSystemPrompt('splitting'); + expect(prompt).toContain('One PR, One Day'); + expect(prompt).toContain('one PR, one day of senior engineer work'); + }); + it('planning prompt includes key instructions', () => { const prompt = getSystemPrompt('planning'); expect(prompt).toContain('ReadWorkItem'); From 7462d2d57700ae8d140b39caafa654b4433bdcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 12 Jun 2026 17:27:06 +0200 Subject: [PATCH 16/25] fix(trello): refresh API key link + stop stuck 'Waiting' spinner (#1399) * fix(trello): update obsolete API key link to power-ups admin The trello.com/app-key page is obsolete; Trello API keys are now managed at trello.com/power-ups/admin. Updates the wizard link and a stale code comment reference. Co-Authored-By: Claude Opus 4.8 * fix(trello): stop 'Waiting' spinner once token is entered manually The OAuth popup only cleared isWaitingForAuth when the popup window was closed. If the user pasted the token manually while the popup was still open, the UI showed 'Token set' but the 'Waiting...' spinner kept running. Clear the waiting state as soon as a token is present. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Zbigniew Sobiecki --- src/router/platformClients/credentials.ts | 2 +- .../projects/pm-providers/trello/oauth-step.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index 5f8468389..6a602147c 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -73,7 +73,7 @@ export async function resolveLinearCredentials( * - `'github'`: resolves the `webhook_secret` credential from the SCM integration. * - `'trello'`: resolves the `api_secret` credential from the PM integration. * Trello computes webhook HMAC signatures using the API Secret (shown below the - * API Key at https://trello.com/app-key), not the public API Key. + * API Key at https://trello.com/power-ups/admin/), not the public API Key. * - `'jira'`: resolves the `webhook_secret` credential from the PM integration. * - `'linear'`: resolves the `webhook_secret` credential from the PM integration. * diff --git a/web/src/components/projects/pm-providers/trello/oauth-step.tsx b/web/src/components/projects/pm-providers/trello/oauth-step.tsx index dfe16102d..73a35923a 100644 --- a/web/src/components/projects/pm-providers/trello/oauth-step.tsx +++ b/web/src/components/projects/pm-providers/trello/oauth-step.tsx @@ -60,6 +60,14 @@ export function TrelloOAuthStep({ state, dispatch }: ProviderWizardStepProps) { return () => clearInterval(interval); }, [isWaitingForAuth]); + // Once a token is provided (e.g. pasted manually after granting access in the + // popup), authorization is complete — stop waiting even if the popup is still open. + useEffect(() => { + if (state.trelloToken && isWaitingForAuth) { + setIsWaitingForAuth(false); + } + }, [state.trelloToken, isWaitingForAuth]); + return (
{state.isEditing && state.hasStoredCredentials && !state.trelloApiKey && ( @@ -84,12 +92,12 @@ export function TrelloOAuthStep({ state, dispatch }: ProviderWizardStepProps) {

Find your API key at{' '} - trello.com/app-key + trello.com/power-ups/admin

From faa92dd4bb904c175c0e875f3b390e9ce850b713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 12 Jun 2026 17:35:11 +0200 Subject: [PATCH 17/25] fix(web): show real PM webhook callback URL in Trello/JIRA wizard (#1401) The Trello and JIRA webhook steps displayed `${origin}/webhooks//` as the webhook URL, but the router serves `//webhook` and the actual registered callback (and the curl example) is `${callbackBaseUrl}//webhook`. The displayed URL and the real callback diverged, so operators copying the shown URL pointed Trello/JIRA at a path the router never handles (it falls through to the dashboard SPA). Derive the displayed `webhookUrl` from `callbackBaseUrl` with the correct `//webhook` suffix so the wizard shows exactly what gets registered. Linear already used the correct `${routerOrigin}/linear/webhook` form. Co-authored-by: Claude Opus 4.8 Co-authored-by: Zbigniew Sobiecki --- web/src/components/projects/pm-providers/jira/wizard.ts | 8 ++++++-- web/src/components/projects/pm-providers/trello/wizard.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index d52f247fa..a3e55141e 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -355,8 +355,6 @@ export const jiraProviderWizard: ProviderWizardDefinition = { customField.createJiraCustomFieldMutation.mutate({ name }); }; - const webhookUrl = projectId ? `${window.location.origin}/webhooks/${projectId}/jira` : ''; - // Plan 012/2 — webhook plumbing. Mirrors the legacy `useWebhookManagement` // formula (plan 012/4 deletes that hook). The server-side // `jiraEnsureLabels` side-effect fires inside @@ -365,6 +363,12 @@ export const jiraProviderWizard: ProviderWizardDefinition = { API_URL || (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + // Display the exact callback URL that actually gets registered — the + // router route is `/jira/webhook`, not a synthetic + // `/webhooks//jira` path the router never serves. The two had + // diverged, so the displayed URL pointed operators at a dead endpoint. + const webhookUrl = callbackBaseUrl ? `${callbackBaseUrl}/jira/webhook` : ''; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeJiraWebhooks = normalizeJiraActiveWebhooks(webhooksQuery.data); diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 0469e67df..0d3c28f48 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -373,8 +373,6 @@ export const trelloProviderWizard: ProviderWizardDefinition = { customField.createCustomFieldMutation.mutate({ name }); }; - const webhookUrl = projectId ? `${window.location.origin}/webhooks/${projectId}/trello` : ''; - // Plan 012/1 — webhook plumbing. Mirrors the legacy `useWebhookManagement` // formula (plan 012/4 deletes that hook). Computes the public router URL // from the Vite env (dev) or current origin (prod), fetches active @@ -384,6 +382,12 @@ export const trelloProviderWizard: ProviderWizardDefinition = { API_URL || (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + // Display the exact callback URL that actually gets registered — the + // router route is `/trello/webhook`, not a synthetic + // `/webhooks//trello` path the router never serves. The two had + // diverged, so the displayed URL pointed operators at a dead endpoint. + const webhookUrl = callbackBaseUrl ? `${callbackBaseUrl}/trello/webhook` : ''; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeTrelloWebhooks = normalizeTrelloActiveWebhooks(webhooksQuery.data); From f089031518a9319f061117a9cfc44d4e74d763c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Tue, 16 Jun 2026 12:03:57 +0200 Subject: [PATCH 18/25] feat(backends): add Claude Opus 4.8 model support (#1402) Adds claude-opus-4-8 and claude-opus-4-8[1m] to the CLAUDE_CODE_MODELS list, rate limits, and cost pricing tables alongside the existing 4.7 entries. Updates the backend unit tests to assert the new model IDs and the expanded model count. Co-authored-by: Claude Sonnet 4.6 --- src/backends/claude-code/models.ts | 2 ++ src/config/rateLimits.ts | 7 +++++++ src/utils/llmMetrics.ts | 2 ++ tests/unit/backends/claude-code.test.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/backends/claude-code/models.ts b/src/backends/claude-code/models.ts index 809f6f036..bab70d9f4 100644 --- a/src/backends/claude-code/models.ts +++ b/src/backends/claude-code/models.ts @@ -1,4 +1,6 @@ export const CLAUDE_CODE_MODELS = [ + { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, + { value: 'claude-opus-4-8[1m]', label: 'Claude Opus 4.8 (1M context)' }, { value: 'claude-opus-4-7', label: 'Claude Opus 4.7' }, { value: 'claude-opus-4-7[1m]', label: 'Claude Opus 4.7 (1M context)' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, diff --git a/src/config/rateLimits.ts b/src/config/rateLimits.ts index 5ab494d42..91988d999 100644 --- a/src/config/rateLimits.ts +++ b/src/config/rateLimits.ts @@ -19,6 +19,13 @@ export const MODEL_RATE_LIMITS: ModelRateLimits = { safetyMargin: 0.8, // Conservative - start throttling at 80% }, + // Claude Opus 4.8 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive) + 'anthropic:claude-opus-4-8': { + requestsPerMinute: 50, + tokensPerMinute: 10_000, + safetyMargin: 0.85, + }, + // Claude Opus 4.7 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive) 'anthropic:claude-opus-4-7': { requestsPerMinute: 50, diff --git a/src/utils/llmMetrics.ts b/src/utils/llmMetrics.ts index b58e9b25e..c55c99806 100644 --- a/src/utils/llmMetrics.ts +++ b/src/utils/llmMetrics.ts @@ -10,6 +10,8 @@ import type { TokenUsage } from 'llmist'; */ const MODEL_PRICING: Record = { // Anthropic Claude 4 family + 'anthropic:claude-opus-4-8': { input: 5.0, output: 25.0, cachedInput: 0.5 }, + 'anthropic:claude-opus-4-8[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-7': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-7[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, 'anthropic:claude-opus-4-6[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 }, diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index 703c833fa..65e386775 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -295,10 +295,12 @@ describe('buildSystemPrompt', () => { describe('CLAUDE_CODE_MODELS constants', () => { it('contains the expected models', () => { - expect(CLAUDE_CODE_MODELS).toHaveLength(8); + expect(CLAUDE_CODE_MODELS).toHaveLength(10); }); - it('includes Opus 4.7 and the 1M context variants', () => { + it('includes Opus 4.8, Opus 4.7, and the 1M context variants', () => { + expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-8'); + expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-8[1m]'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-7'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-4-7[1m]'); expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-sonnet-4-6[1m]'); @@ -323,6 +325,8 @@ describe('CLAUDE_CODE_MODELS constants', () => { describe('resolveClaudeModel', () => { it('passes through known Claude Code model IDs', () => { + expect(resolveClaudeModel('claude-opus-4-8')).toBe('claude-opus-4-8'); + expect(resolveClaudeModel('claude-opus-4-8[1m]')).toBe('claude-opus-4-8[1m]'); expect(resolveClaudeModel('claude-opus-4-7')).toBe('claude-opus-4-7'); expect(resolveClaudeModel('claude-opus-4-7[1m]')).toBe('claude-opus-4-7[1m]'); expect(resolveClaudeModel('claude-sonnet-4-6[1m]')).toBe('claude-sonnet-4-6[1m]'); From 58f5754898060d8e1042915c216457ed3e3d8854 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 19 Jun 2026 14:47:47 +0200 Subject: [PATCH 19/25] fix(router): self-heal on missing base worker image + stub failed run row (#1408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(router): self-heal on missing base worker image + stub failed run row When the base worker image (`cascade-worker:latest`) is pruned from the prod Docker host, every spawn attempt today throws an UnrecoverableError and the job dies silently — no run row is ever created (the worker is what calls `tryCreateRun`), so `cascade runs list` shows nothing. The 2026-06-15 Damisa outage class. Two layers: 1. `pullImageOnce` in worker-snapshots — single-flight Docker image pull with a 5-minute timeout. spawnWorker's catch path now calls it when the base image is 404, then retries `launchConfiguredWorkerContainer` once. Snapshot 404s still flow through the existing snapshot fallback (pulling a local commit never helps). 2. `recordSpawnFailure` in container-manager — best-effort `createRun` + `completeRun(failed)` stub so terminal spawn failures surface in the dashboard and `cascade runs list`. Engine is resolved from project config, falling back to `'unknown'`. DB errors are swallowed at WARN so they can't mask the original spawn error. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ci): resolve production dependency audit * refactor(router): address review — propagate pullErr, move stub to failed-event hook Review concerns from PR #1408: 1. **Pull-failure classification.** launchOrPullAndRetry was throwing the original ImageNotFound when pull failed, masking transient pull errors (registry 429, ECONNRESET) as terminal. Now throws pullErr directly so the dispatch-error classifier sees its actual shape and can burn a BullMQ retry instead of failing terminally. 2. **Stub row inserted on retryable failures.** recordSpawnFailure ran inside spawnWorker's catch block, so transient Docker errors that BullMQ later recovered from left a misleading 'failed' row behind. Moved to BullMQ's worker.on('failed') hook in bullmq-workers.ts — alongside the existing releaseLocksForFailedJob — so it fires exactly once per permanently-dead job (retry budget exhausted, or UnrecoverableError wrapped). The new export lives in dispatch-compensator.ts next to the other failed-event compensator. Tests: - Dropped the four spawnWorker stub-row scenarios (now wrong layer). - Added a pull-error-propagation test in container-manager.test.ts. - Added six recordSpawnFailureStub scenarios in dispatch-compensator.test.ts (createRun payload, engine resolution from project config, fallback to 'unknown' on config-read failure, projectId-null skip, agentType-null skip, never-throws-on-DB-failure). - Updated snapshot-integration test to expect self-heal (pull + retry) instead of terminal-on-base-404, and re-armed pull/followProgress defaults after vi.restoreAllMocks() in the file-wide beforeEach. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ci): resolve production dependency audit * fix(router): gate spawn-failure stub to terminal failures; trim lockfile drift BullMQ emits `failed` on EVERY attempt — `Worker.handleFailed` calls `job.moveToFailed()` then `emit('failed')` unconditionally. On a retryable attempt it re-queues to `delayed` and leaves `finishedOn` unset; only a terminal failure (retries exhausted / UnrecoverableError) sets it. Since spec 015 deliberately propagates transient spawn errors (registry 429 / ECONNRESET) so BullMQ retries them via `attempts: 4`, `recordSpawnFailureStub` running on every emission planted 1–3 bogus `failed` run rows for a transient error that later succeeded — the exact case the prior code comment wrongly claimed was already handled. Add `isTerminalDispatchFailure(job, err)` (canonical `finishedOn` signal, with exhausted-attempts + UnrecoverableError fallbacks) and gate the stub call on it; lock compensation still runs on every attempt. Covered by unit tests for both the predicate and the handler (retry → no stub; terminal → stub). Lockfile: the review's "strip to minimal" ask reintroduces 2 high-severity advisories (form-data CRLF, protobufjs schema-shadow/DoS) and breaks the `npm audit --audit-level=high` gate. Instead, pin the two patched versions via overrides (protobufjs ^7.6.4, form-data ^4.0.6) and regenerate from the dev baseline — drops the 92-package drift (1781→64 lockfile lines) while keeping the audit gate green. Unrelated bumps (@sentry/node, tsx, vitest, js-yaml, claude-agent-sdk) reverted to dev versions. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(test): expose UnrecoverableError on bullmq mock in dispatch-failure test e05009dd added isTerminalDispatchFailure which does err instanceof UnrecoverableError, but this integration test's bullmq mock only exposed Worker. Vitest's strict export check fails at the predicate call site. Mirror the real-enough subclass already in tests/unit/router/bullmq-workers.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Cascade Bot --- package-lock.json | 64 +++++---- package.json | 7 +- src/router/bullmq-workers.ts | 53 ++++++- src/router/container-manager.ts | 73 +++++++++- src/router/dispatch-compensator.ts | 59 ++++++++ src/router/worker-snapshots.ts | 47 ++++++ .../dispatch-failure-compensation.test.ts | 8 ++ tests/unit/router/bullmq-workers.test.ts | 130 ++++++++++++++++- tests/unit/router/container-manager.test.ts | 61 ++++++++ .../unit/router/dispatch-compensator.test.ts | 134 +++++++++++++++++- .../unit/router/snapshot-integration.test.ts | 35 ++++- tests/unit/router/worker-snapshots.test.ts | 68 +++++++++ 12 files changed, 689 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c0d789eb..dededa5fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.23", + "hono": "^4.12.25", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^16.0.4", @@ -62,7 +62,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^3.2.6", "commander": "^14.0.2", "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", @@ -3282,6 +3282,8 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { @@ -3295,27 +3297,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -4164,9 +4163,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", "dev": true, "license": "MIT", "dependencies": { @@ -4188,8 +4187,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -7145,14 +7144,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7452,7 +7453,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7471,9 +7474,9 @@ } }, "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -9443,24 +9446,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" diff --git a/package.json b/package.json index 200f58db4..025651350 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "eta": "^4.5.0", "execa": "^9.6.1", "fastest-levenshtein": "^1.0.16", - "hono": "^4.12.23", + "hono": "^4.12.25", "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^16.0.4", @@ -103,7 +103,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^3.2.6", "commander": "^14.0.2", "concurrently": "^10.0.3", "drizzle-kit": "^0.31.9", @@ -139,6 +139,7 @@ "lodash-es": "^4.18.1", "brace-expansion": "^2.0.3", "axios": "^1.15.0", - "protobufjs": "^7.5.8" + "protobufjs": "^7.6.4", + "form-data": "^4.0.6" } } diff --git a/src/router/bullmq-workers.ts b/src/router/bullmq-workers.ts index c02a4ce25..267a9e2e0 100644 --- a/src/router/bullmq-workers.ts +++ b/src/router/bullmq-workers.ts @@ -6,15 +6,49 @@ * and Sentry capture). */ -import { type ConnectionOptions, type Job, Worker } from 'bullmq'; +import { type ConnectionOptions, type Job, UnrecoverableError, Worker } from 'bullmq'; import { captureException } from '../sentry.js'; import { logger } from '../utils/logging.js'; import { parseRedisUrl } from '../utils/redis.js'; -import { releaseLocksForFailedJob } from './dispatch-compensator.js'; +import { recordSpawnFailureStub, releaseLocksForFailedJob } from './dispatch-compensator.js'; // Re-export so existing callers (worker-manager.ts) don't need to change imports. export { parseRedisUrl }; +/** + * BullMQ emits the `failed` event on EVERY attempt, including intermediate + * retries: `Worker.handleFailed` calls `job.moveToFailed(...)` and then + * `emit('failed', ...)` unconditionally. On a retryable attempt + * (`attemptsMade < attempts` and the error is not an `UnrecoverableError`), + * `moveToFailed` re-queues the job to `delayed` and leaves `finishedOn` UNSET; + * only a terminal failure (retries exhausted, or `UnrecoverableError`) sets + * `finishedOn`. + * + * Spec 015 deliberately propagates transient spawn errors (registry 429 / + * ECONNRESET / ECONNREFUSED / ENOTFOUND / 409 / SLOT_WAIT_TIMEOUT) unchanged so + * BullMQ retries them via `attempts: 4`. A side effect — recorded as a `failed` + * stub run row per emission — would therefore plant one bogus row per + * intermediate retry for a transient error that later succeeds. So any such + * side effect must run ONLY on a terminal failure. + * + * `finishedOn` is the canonical terminal signal and matches BullMQ's own + * retry/terminal branch; the exhausted-attempts and `UnrecoverableError` checks + * are defensive fallbacks should a BullMQ build leave `finishedOn` unset on a + * terminal emission. The name check guards against a cross-realm/duplicate-copy + * `UnrecoverableError` that fails `instanceof`. + */ +export function isTerminalDispatchFailure(job: Job, err: unknown): boolean { + if (typeof job.finishedOn === 'number') return true; + if ( + err instanceof UnrecoverableError || + (err as { name?: string } | null)?.name === 'UnrecoverableError' + ) { + return true; + } + const attempts = job.opts?.attempts; + return typeof attempts === 'number' && attempts > 0 && job.attemptsMade >= attempts; +} + export interface QueueWorkerConfig { queueName: string; /** Human-readable label used in log messages and Sentry tags */ @@ -72,6 +106,21 @@ export function createQueueWorker(config: QueueWorkerConfig): Wo tags: { source: 'dispatch_compensator_uncaught', queue: queueName }, }); }); + // Insert a `failed` stub run row so the dispatch is visible in the + // dashboard / `cascade runs list`. The `failed` event fires on EVERY + // attempt, so gate this to a TERMINAL failure — otherwise a transient + // spawn error (deliberately retried per spec 015) that later succeeds + // would leave one bogus `failed` row per intermediate retry. Lock + // compensation above must still run on every attempt. Recorder never + // throws. See `isTerminalDispatchFailure`. + if (isTerminalDispatchFailure(job, err)) { + void recordSpawnFailureStub(job.data, err).catch((stubErr) => { + logger.warn('[WorkerManager] stub-row recorder threw — defensively logged', { + jobId: job.id, + error: String(stubErr), + }); + }); + } } }); diff --git a/src/router/container-manager.ts b/src/router/container-manager.ts index 056a76a9b..ce4785091 100644 --- a/src/router/container-manager.ts +++ b/src/router/container-manager.ts @@ -33,7 +33,7 @@ import { extractProjectIdFromJob, extractWorkItemId, } from './worker-env.js'; -import { isImageNotFoundError } from './worker-snapshots.js'; +import { isImageNotFoundError, pullImageOnce } from './worker-snapshots.js'; import { buildWorkerContainerName, resolveSpawnSettings } from './worker-spawn-settings.js'; // Re-export from sub-modules so existing callers importing from container-manager.ts @@ -100,6 +100,73 @@ async function launchConfiguredWorkerContainer( ); } +/** + * Launch a worker container; if the **base** image is missing, pull it once and + * retry. Snapshot-image 404s propagate so the snapshot fallback path in + * `spawnWorker` still fires — snapshot images are local commits, not in any + * registry, so pulling them never helps. + * + * Closes the 2026-06-15 outage class where a host-side prune of + * `cascade-worker:latest` produced silent terminal `UnrecoverableError`s for + * every spawn — see the post-mortem in `docs/specs/` and the dispatch-error + * classifier comment that already promised this behaviour. + */ +async function launchOrPullAndRetry( + job: Job, + jobId: string, + containerName: string, + projectId: string | null, + workItemId: string | undefined, + agentType: string | undefined, + config: WorkerContainerLaunchConfig, +): Promise { + try { + await launchConfiguredWorkerContainer( + job, + jobId, + containerName, + projectId, + workItemId, + agentType, + config, + ); + } catch (err) { + if (!isImageNotFoundError(err) || config.workerImage !== routerConfig.workerImage) { + throw err; + } + const imageName = config.workerImage; + logger.info('[WorkerManager] Base worker image missing — pulling', { jobId, imageName }); + try { + await pullImageOnce(imageName); + } catch (pullErr) { + logger.error('[WorkerManager] Failed to pull base worker image:', { + jobId, + imageName, + error: String(pullErr), + }); + captureException(pullErr, { + tags: { source: 'worker_image_pull_fallback', jobType: job.data.type }, + extra: { jobId, imageName }, + }); + // Propagate the pull error (not the original 404) so the dispatch-error + // classifier can see its actual shape — registry 429s, ECONNRESET, and + // other transient pull failures should burn a BullMQ retry instead of + // being misclassified as terminal via `isImageNotFoundError`. + throw pullErr; + } + logger.info('[WorkerManager] Base image pulled, retrying spawn', { jobId, imageName }); + await launchConfiguredWorkerContainer( + job, + jobId, + containerName, + projectId, + workItemId, + agentType, + config, + ); + } +} + /** * Spawn a worker container for a job. * Sets up timeout tracking and monitors container exit asynchronously. @@ -159,7 +226,7 @@ export async function spawnWorker(job: Job): Promise { }; try { - await launchConfiguredWorkerContainer( + await launchOrPullAndRetry( job, jobId, containerName, @@ -185,7 +252,7 @@ export async function spawnWorker(job: Job): Promise { workerEnv: fallbackEnv, }; try { - await launchConfiguredWorkerContainer( + await launchOrPullAndRetry( job, jobId, containerName, diff --git a/src/router/dispatch-compensator.ts b/src/router/dispatch-compensator.ts index 826c9f5cb..c542b56f6 100644 --- a/src/router/dispatch-compensator.ts +++ b/src/router/dispatch-compensator.ts @@ -12,9 +12,13 @@ * BullMQ worker; instead we capture to Sentry and log, then resolve. */ +import { resolveEngineName } from '../backends/resolution.js'; +import { completeRun, createRun } from '../db/repositories/runsRepository.js'; import { captureException } from '../sentry.js'; +import type { TriggerResult } from '../types/index.js'; import { logger } from '../utils/logging.js'; import { clearAgentTypeEnqueued, clearRecentlyDispatched } from './agent-type-lock.js'; +import { loadProjectConfig } from './config.js'; import type { CascadeJob } from './queue.js'; import { clearWorkItemEnqueued } from './work-item-lock.js'; import { extractAgentType, extractProjectIdFromJob, extractWorkItemId } from './worker-env.js'; @@ -47,3 +51,58 @@ export async function releaseLocksForFailedJob(data: unknown): Promise { }); } } + +/** + * Insert a `failed` stub run row so a dispatch that never produced a worker + * still surfaces in the dashboard and `cascade runs list`. Called from + * BullMQ's `worker.on('failed')` handler, so it fires exactly once per + * permanently-failed job — either after the retry budget is exhausted + * (transient) or immediately when `UnrecoverableError` is wrapped (terminal). + * Intermediate retries do NOT trigger it, so a transient Docker socket error + * that BullMQ later recovers from leaves no stub behind to mislead operators. + * + * Without this row, the worker (which calls `tryCreateRun` at boot) never + * runs, `failOrphanedRun` no-ops because there is no `status='running'` row, + * and the failure is invisible outside Sentry — the 2026-06-15 Damisa + * outage class. + * + * Best-effort: any DB failure here is logged at WARN and swallowed. + */ +export async function recordSpawnFailureStub(data: unknown, err: unknown): Promise { + try { + const projectId = await extractProjectIdFromJob(data as CascadeJob); + if (!projectId) return; + const agentType = extractAgentType(data as CascadeJob); + if (!agentType) return; + const workItemId = extractWorkItemId(data as CascadeJob); + const triggerResult = (data as { triggerResult?: TriggerResult }).triggerResult; + const prNumber = triggerResult?.prNumber; + const triggerType = triggerResult?.agentInput?.triggerType; + let engine = 'unknown'; + try { + const { fullProjects } = await loadProjectConfig(); + const projectCfg = fullProjects.find((p) => p.id === projectId); + if (projectCfg) engine = resolveEngineName(agentType, projectCfg); + } catch { + // engine column is NOT NULL — fall through with 'unknown' rather + // than letting a config-read problem block the visibility stub. + } + const runId = await createRun({ + projectId, + workItemId, + prNumber, + agentType, + engine, + triggerType, + }); + await completeRun(runId, { + status: 'failed', + durationMs: 0, + error: `Worker spawn failed: ${String(err)}`, + }); + } catch (dbErr) { + logger.warn('[dispatch-compensator] failed to record spawn-failure stub run', { + error: String(dbErr), + }); + } +} diff --git a/src/router/worker-snapshots.ts b/src/router/worker-snapshots.ts index d3e2ad84a..0a316f9a3 100644 --- a/src/router/worker-snapshots.ts +++ b/src/router/worker-snapshots.ts @@ -108,3 +108,50 @@ export function isImageNotFoundError(err: unknown): boolean { String(err).toLowerCase().includes('no such image') ); } + +/** Default budget for an on-demand image pull triggered by base-image self-heal. */ +export const IMAGE_PULL_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Single-flight in-flight pull cache. A second caller for the same image while + * the first pull is running awaits the same promise instead of triggering a + * concurrent pull. The entry is cleared on settle so a subsequent prune still + * triggers a fresh pull next time. + */ +const inFlightPulls = new Map>(); + +/** + * Pull a Docker image, deduplicating concurrent requests by image name and + * enforcing a wall-clock timeout. + * + * Used by the spawn self-heal path in `container-manager.ts` when the base + * worker image was pruned from the host between spawns. Failure cases: + * - Pull stream emits an error → reject with that error. + * - Pull exceeds `timeoutMs` → reject with a `pull timeout` error; the + * underlying stream is abandoned (no cancel hook in dockerode). + * - Registry auth missing / network down → propagates the dockerode error; + * the caller still has the original 404 to re-throw. + */ +export function pullImageOnce(imageName: string, timeoutMs = IMAGE_PULL_TIMEOUT_MS): Promise { + const existing = inFlightPulls.get(imageName); + if (existing) return existing; + + const promise = (async () => { + const pullStream = (await docker.pull(imageName)) as NodeJS.ReadableStream; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`pull timeout after ${timeoutMs}ms for ${imageName}`)); + }, timeoutMs); + docker.modem.followProgress(pullStream, (err: Error | null) => { + clearTimeout(timer); + if (err) reject(err); + else resolve(); + }); + }); + })().finally(() => { + inFlightPulls.delete(imageName); + }); + + inFlightPulls.set(imageName, promise); + return promise; +} diff --git a/tests/integration/router/dispatch-failure-compensation.test.ts b/tests/integration/router/dispatch-failure-compensation.test.ts index c9a945fe9..958bab51a 100644 --- a/tests/integration/router/dispatch-failure-compensation.test.ts +++ b/tests/integration/router/dispatch-failure-compensation.test.ts @@ -18,6 +18,14 @@ vi.mock('bullmq', () => ({ Worker: vi.fn().mockImplementation((_queueName, _processFn, _opts) => ({ on: vi.fn(), })), + // `isTerminalDispatchFailure` does `err instanceof UnrecoverableError` — + // expose a real-enough subclass so the predicate works under the mock. + UnrecoverableError: class UnrecoverableError extends Error { + constructor(message?: string) { + super(message); + this.name = 'UnrecoverableError'; + } + }, })); vi.mock('../../../src/sentry.js', () => ({ diff --git a/tests/unit/router/bullmq-workers.test.ts b/tests/unit/router/bullmq-workers.test.ts index bc6ddc419..219deed38 100644 --- a/tests/unit/router/bullmq-workers.test.ts +++ b/tests/unit/router/bullmq-workers.test.ts @@ -8,6 +8,13 @@ vi.mock('bullmq', () => ({ Worker: vi.fn().mockImplementation((_queueName, _processFn, _opts) => ({ on: vi.fn(), })), + // Real-enough subclass so `instanceof` in the predicate works under the mock. + UnrecoverableError: class UnrecoverableError extends Error { + constructor(message?: string) { + super(message); + this.name = 'UnrecoverableError'; + } + }, })); vi.mock('../../../src/sentry.js', () => ({ @@ -16,6 +23,7 @@ vi.mock('../../../src/sentry.js', () => ({ vi.mock('../../../src/router/dispatch-compensator.js', () => ({ releaseLocksForFailedJob: vi.fn().mockResolvedValue(undefined), + recordSpawnFailureStub: vi.fn().mockResolvedValue(undefined), })); // Mock logger @@ -32,9 +40,16 @@ vi.mock('../../../src/utils/logging.js', () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- -import { Worker } from 'bullmq'; -import { createQueueWorker, parseRedisUrl } from '../../../src/router/bullmq-workers.js'; -import { releaseLocksForFailedJob } from '../../../src/router/dispatch-compensator.js'; +import { UnrecoverableError, Worker } from 'bullmq'; +import { + createQueueWorker, + isTerminalDispatchFailure, + parseRedisUrl, +} from '../../../src/router/bullmq-workers.js'; +import { + recordSpawnFailureStub, + releaseLocksForFailedJob, +} from '../../../src/router/dispatch-compensator.js'; import { captureException } from '../../../src/sentry.js'; import { logger } from '../../../src/utils/logging.js'; @@ -42,12 +57,15 @@ const MockWorker = vi.mocked(Worker); const mockCaptureException = vi.mocked(captureException); const mockLogger = vi.mocked(logger); const mockReleaseLocksForFailedJob = vi.mocked(releaseLocksForFailedJob); +const mockRecordSpawnFailureStub = vi.mocked(recordSpawnFailureStub); beforeEach(() => { MockWorker.mockClear(); mockCaptureException.mockClear(); mockReleaseLocksForFailedJob.mockClear(); mockReleaseLocksForFailedJob.mockResolvedValue(undefined); + mockRecordSpawnFailureStub.mockClear(); + mockRecordSpawnFailureStub.mockResolvedValue(undefined); // Re-establish default mock so each test gets a fresh mock worker MockWorker.mockImplementation( (_queueName, _processFn, _opts) => @@ -267,4 +285,110 @@ describe('createQueueWorker', () => { // Existing log + Sentry behavior preserved expect(mockLogger.error).toHaveBeenCalled(); }); + + // ------------------------------------------------------------------------- + // Spawn-failure stub gating — the failed event fires on EVERY attempt + // (including intermediate retries); the stub must only be recorded on a + // terminal failure, or transient retries leave bogus `failed` run rows. + // ------------------------------------------------------------------------- + + type FailedHandler = (job: Record | undefined, err: unknown) => void; + + function getFailedHandler(): FailedHandler { + const worker = createQueueWorker(baseConfig); + return vi.mocked(worker.on).mock.calls.find((c) => c[0] === 'failed')?.[1] as FailedHandler; + } + + it('records the spawn-failure stub on a terminal failure (finishedOn set)', () => { + const jobData = { type: 'github', payload: 'x' }; + getFailedHandler()( + { id: 'job-term', data: jobData, attemptsMade: 4, opts: { attempts: 4 }, finishedOn: 1234 }, + new Error('image not found after fallback'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + expect(mockRecordSpawnFailureStub).toHaveBeenCalledWith(jobData, expect.any(Error)); + }); + + it('does NOT record the stub on an intermediate retry (finishedOn unset, attempts remain)', () => { + getFailedHandler()( + { id: 'job-retry', data: { type: 'github' }, attemptsMade: 1, opts: { attempts: 4 } }, + new Error('ECONNRESET pulling image'), + ); + + expect(mockRecordSpawnFailureStub).not.toHaveBeenCalled(); + // Lock compensation must STILL fire on every attempt — only the run-row + // stub is gated. + expect(mockReleaseLocksForFailedJob).toHaveBeenCalledTimes(1); + }); + + it('records the stub when retries are exhausted even if finishedOn is unset (defensive fallback)', () => { + getFailedHandler()( + { id: 'job-exhausted', data: { type: 'github' }, attemptsMade: 4, opts: { attempts: 4 } }, + new Error('still failing'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + }); + + it('records the stub for an UnrecoverableError regardless of remaining attempts', () => { + getFailedHandler()( + { id: 'job-unrec', data: { type: 'github' }, attemptsMade: 1, opts: { attempts: 4 } }, + new UnrecoverableError('validation failed'), + ); + + expect(mockRecordSpawnFailureStub).toHaveBeenCalledTimes(1); + }); + + it('swallows a throw from the stub recorder', async () => { + mockRecordSpawnFailureStub.mockRejectedValueOnce(new Error('stub boom')); + expect(() => + getFailedHandler()( + { id: 'job-stubthrow', data: { type: 'github' }, finishedOn: 99 }, + new Error('terminal'), + ), + ).not.toThrow(); + await new Promise((r) => setImmediate(r)); + }); +}); + +// --------------------------------------------------------------------------- +// isTerminalDispatchFailure — predicate behind the stub gate +// --------------------------------------------------------------------------- + +describe('isTerminalDispatchFailure', () => { + // Minimal Job-shaped fixtures; the predicate reads only finishedOn / attemptsMade / opts. + const job = (over: Record) => over as never; + + it('is terminal when BullMQ set finishedOn', () => { + expect(isTerminalDispatchFailure(job({ finishedOn: 1 }), new Error('x'))).toBe(true); + }); + + it('is NOT terminal mid-retry (no finishedOn, attempts remain)', () => { + expect( + isTerminalDispatchFailure(job({ attemptsMade: 1, opts: { attempts: 4 } }), new Error('x')), + ).toBe(false); + }); + + it('is terminal once attemptsMade reaches the attempt budget', () => { + expect( + isTerminalDispatchFailure(job({ attemptsMade: 4, opts: { attempts: 4 } }), new Error('x')), + ).toBe(true); + }); + + it('is terminal for an UnrecoverableError even with attempts remaining', () => { + expect( + isTerminalDispatchFailure( + job({ attemptsMade: 1, opts: { attempts: 4 } }), + new UnrecoverableError('terminal'), + ), + ).toBe(true); + }); + + it('treats an error named UnrecoverableError as terminal (cross-realm safety)', () => { + const err = Object.assign(new Error('x'), { name: 'UnrecoverableError' }); + expect(isTerminalDispatchFailure(job({ attemptsMade: 1, opts: { attempts: 4 } }), err)).toBe( + true, + ); + }); }); diff --git a/tests/unit/router/container-manager.test.ts b/tests/unit/router/container-manager.test.ts index 01a03e6ac..d69009950 100644 --- a/tests/unit/router/container-manager.test.ts +++ b/tests/unit/router/container-manager.test.ts @@ -8,11 +8,15 @@ const { mockDockerCreateContainer, mockDockerGetContainer, mockDockerListContainers, + mockDockerPull, + mockFollowProgress, mockLoadProjectConfig, } = vi.hoisted(() => ({ mockDockerCreateContainer: vi.fn(), mockDockerGetContainer: vi.fn(), mockDockerListContainers: vi.fn(), + mockDockerPull: vi.fn(), + mockFollowProgress: vi.fn(), mockLoadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), })); @@ -25,6 +29,8 @@ vi.mock('dockerode', () => ({ createContainer: mockDockerCreateContainer, getContainer: mockDockerGetContainer, listContainers: mockDockerListContainers, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -257,6 +263,11 @@ describe('spawnWorker', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); + mockDockerPull.mockResolvedValue({} as never); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); + mockDockerPull.mockClear(); + mockFollowProgress.mockClear(); detachAll(); }); @@ -399,6 +410,56 @@ describe('spawnWorker', () => { resolveWait(); }); + // Self-heal + visibility on missing base image — the 2026-06-15 outage class. + // Without these, a host-side prune of `cascade-worker:latest` produces silent + // `UnrecoverableError`s on every spawn and `runs list` shows nothing at all. + + it('self-heals when the base image is missing: pulls once and retries spawn', async () => { + const notFound = Object.assign( + new Error('(HTTP code 404) no such container - No such image: test-worker:latest'), + { statusCode: 404 }, + ); + mockDockerCreateContainer.mockRejectedValueOnce(notFound); + const { resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob({ id: 'job-pull-heal' }) as never); + + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledWith('test-worker:latest'); + expect(mockDockerCreateContainer).toHaveBeenCalledTimes(2); + + resolveWait(); + }); + + it('propagates the pull error (not the original 404) when pull fails so the dispatch-error classifier can retry transient pull errors', async () => { + // Reviewer concern: throwing the original ImageNotFound on pull failure + // converts transient pull errors (registry 429, ECONNRESET) into terminal + // classification. spawnWorker must surface the pull error itself so the + // classifier can examine its actual shape. + const notFound = Object.assign( + new Error('(HTTP code 404) no such container - No such image: test-worker:latest'), + { statusCode: 404 }, + ); + mockDockerCreateContainer.mockRejectedValue(notFound); + const transientPullErr = Object.assign(new Error('registry 429'), { statusCode: 429 }); + mockFollowProgress.mockImplementation(((_s: unknown, cb: (e: Error | null) => void) => + cb(transientPullErr)) as never); + + await expect( + spawnWorker( + makeJob({ + id: 'job-pull-transient', + data: { + type: 'trello', + projectId: 'proj-tr', + workItemId: 'card-tr', + agentType: 'review', + } as CascadeJob, + }) as never, + ), + ).rejects.toBe(transientPullErr); + }); + it('uses project watchdogTimeoutMs + 2min buffer when available', async () => { mockLoadProjectConfig.mockResolvedValue({ projects: [], diff --git a/tests/unit/router/dispatch-compensator.test.ts b/tests/unit/router/dispatch-compensator.test.ts index 8ec7441c0..b1e9927d1 100644 --- a/tests/unit/router/dispatch-compensator.test.ts +++ b/tests/unit/router/dispatch-compensator.test.ts @@ -25,11 +25,26 @@ vi.mock('../../../src/utils/logging.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); +const mockCreateRun = vi.fn().mockResolvedValue('stub-run-id'); +const mockCompleteRun = vi.fn().mockResolvedValue(undefined); +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + createRun: (...args: unknown[]) => mockCreateRun(...args), + completeRun: (...args: unknown[]) => mockCompleteRun(...args), +})); + +const mockLoadProjectConfig = vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }); +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: (...args: unknown[]) => mockLoadProjectConfig(...args), +})); + import { clearAgentTypeEnqueued, clearRecentlyDispatched, } from '../../../src/router/agent-type-lock.js'; -import { releaseLocksForFailedJob } from '../../../src/router/dispatch-compensator.js'; +import { + recordSpawnFailureStub, + releaseLocksForFailedJob, +} from '../../../src/router/dispatch-compensator.js'; import { clearWorkItemEnqueued } from '../../../src/router/work-item-lock.js'; import { extractAgentType, @@ -146,3 +161,120 @@ describe('releaseLocksForFailedJob', () => { expect(mockClearRecentlyDispatched).not.toHaveBeenCalled(); }); }); + +// Lives next to releaseLocksForFailedJob because it ALSO runs from BullMQ's +// `worker.on('failed')` handler, fires exactly once per permanently-dead job, +// and shares the same extractor/extraction shape. Reviewer concern from PR +// #1408: the recorder must NOT run on retryable failures that BullMQ later +// recovers from — that surface is guaranteed here, not in spawnWorker's catch. +describe('recordSpawnFailureStub', () => { + beforeEach(() => { + mockExtractProjectIdFromJob.mockReset(); + mockExtractWorkItemId.mockReset(); + mockExtractAgentType.mockReset(); + mockCreateRun.mockReset().mockResolvedValue('stub-run-id'); + mockCompleteRun.mockReset().mockResolvedValue(undefined); + mockLoadProjectConfig.mockReset().mockResolvedValue({ projects: [], fullProjects: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('inserts a failed run row with extracted projectId, workItemId, prNumber, agentType, and triggerType', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p1'); + mockExtractWorkItemId.mockReturnValue('w1'); + mockExtractAgentType.mockReturnValue('review'); + + const err = new Error('worker died at boot'); + await recordSpawnFailureStub( + { + type: 'github', + triggerResult: { + prNumber: 2273, + agentInput: { triggerType: 'review-requested' }, + }, + }, + err, + ); + + expect(mockCreateRun).toHaveBeenCalledWith({ + projectId: 'p1', + workItemId: 'w1', + prNumber: 2273, + agentType: 'review', + engine: 'unknown', + triggerType: 'review-requested', + }); + expect(mockCompleteRun).toHaveBeenCalledWith( + 'stub-run-id', + expect.objectContaining({ + status: 'failed', + durationMs: 0, + error: expect.stringContaining('worker died at boot'), + }), + ); + }); + + it('resolves the engine from project config when available', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p2'); + mockExtractWorkItemId.mockReturnValue(undefined); + mockExtractAgentType.mockReturnValue('implementation'); + mockLoadProjectConfig.mockResolvedValue({ + projects: [], + fullProjects: [ + { + id: 'p2', + agentEngine: { default: 'codex', overrides: { implementation: 'opencode' } }, + }, + ], + }); + + await recordSpawnFailureStub({ type: 'trello' }, new Error('boom')); + + expect(mockCreateRun).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'p2', agentType: 'implementation', engine: 'opencode' }), + ); + }); + + it('falls back to engine="unknown" when loadProjectConfig throws (must not block visibility)', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p3'); + mockExtractWorkItemId.mockReturnValue('w3'); + mockExtractAgentType.mockReturnValue('review'); + mockLoadProjectConfig.mockRejectedValue(new Error('config read failed')); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).toHaveBeenCalledWith(expect.objectContaining({ engine: 'unknown' })); + }); + + it('skips the row when projectId is null', async () => { + mockExtractProjectIdFromJob.mockResolvedValue(null); + mockExtractAgentType.mockReturnValue('review'); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).not.toHaveBeenCalled(); + }); + + it('skips the row when agentType cannot be resolved', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p4'); + mockExtractAgentType.mockReturnValue(undefined); + + await recordSpawnFailureStub({ type: 'github' }, new Error('boom')); + + expect(mockCreateRun).not.toHaveBeenCalled(); + }); + + it('never throws even if createRun rejects', async () => { + mockExtractProjectIdFromJob.mockResolvedValue('p5'); + mockExtractWorkItemId.mockReturnValue('w5'); + mockExtractAgentType.mockReturnValue('review'); + mockCreateRun.mockRejectedValue(new Error('DB down')); + + await expect( + recordSpawnFailureStub({ type: 'github' }, new Error('boom')), + ).resolves.toBeUndefined(); + expect(mockCompleteRun).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/router/snapshot-integration.test.ts b/tests/unit/router/snapshot-integration.test.ts index fcffc6708..8dcf56c65 100644 --- a/tests/unit/router/snapshot-integration.test.ts +++ b/tests/unit/router/snapshot-integration.test.ts @@ -17,6 +17,8 @@ const { mockDockerCreateContainer, mockDockerGetContainer, mockDockerGetImage, + mockDockerPull, + mockFollowProgress, mockLoadProjectConfig, mockGetSnapshot, mockRegisterSnapshot, @@ -30,6 +32,10 @@ const { mockDockerGetImage: vi.fn().mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Size: 1_234_567_890 }), }), + mockDockerPull: vi.fn().mockResolvedValue({}), + mockFollowProgress: vi + .fn() + .mockImplementation((_stream: unknown, cb: (err: Error | null) => void) => cb(null)), mockLoadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), mockGetSnapshot: vi.fn().mockReturnValue(undefined), mockRegisterSnapshot: vi.fn(), @@ -45,6 +51,8 @@ vi.mock('dockerode', () => ({ createContainer: mockDockerCreateContainer, getContainer: mockDockerGetContainer, getImage: mockDockerGetImage, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -188,6 +196,12 @@ beforeEach(() => { mockDockerGetImage.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Size: 1_234_567_890 }), }); + // Pull + followProgress defaults also get wiped by per-describe + // vi.restoreAllMocks() — re-arm them so the spawn self-heal path's + // optional pull doesn't hang on a no-op followProgress. + mockDockerPull.mockResolvedValue({}); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); }); // --------------------------------------------------------------------------- @@ -591,22 +605,29 @@ describe('spawnWorker — stale snapshot (image not found fallback)', () => { expect(mockInvalidateSnapshot).toHaveBeenCalledWith('proj-snap', 'card-snap'); }); - it('propagates 404 without retry when base image is missing (snapshotReuse=false)', async () => { - // No snapshot hit — fresh run, snapshotReuse will be false + it('self-heals when base image is missing (snapshotReuse=false): pulls then retries spawn', async () => { + // No snapshot hit — fresh run, snapshotReuse will be false. The catch + // path now treats a missing base image as recoverable: pull once, retry + // once. Closes the 2026-06-15 outage class where a pruned base image + // produced silent UnrecoverableErrors on every spawn. mockGetSnapshot.mockReturnValue(undefined); const baseImageError = Object.assign( new Error('(HTTP code 404) no such container - No such image: base-worker:latest'), { statusCode: 404 }, ); mockDockerCreateContainer.mockRejectedValueOnce(baseImageError); + const { resolveWait } = setupMockContainer(); - await expect(spawnWorker(makeJob() as never)).rejects.toThrow( - 'No such image: base-worker:latest', - ); + await spawnWorker(makeJob() as never); - // Should not retry — only one createContainer call - expect(mockDockerCreateContainer).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockDockerPull).toHaveBeenCalledWith('base-worker:latest'); + expect(mockDockerCreateContainer).toHaveBeenCalledTimes(2); + // Snapshot invalidation only applies to stale snapshots; base-image + // recovery does not touch the snapshot registry. expect(mockInvalidateSnapshot).not.toHaveBeenCalled(); + + resolveWait(); }); }); diff --git a/tests/unit/router/worker-snapshots.test.ts b/tests/unit/router/worker-snapshots.test.ts index ac500a578..c6bc19aa8 100644 --- a/tests/unit/router/worker-snapshots.test.ts +++ b/tests/unit/router/worker-snapshots.test.ts @@ -6,6 +6,8 @@ const { mockContainerRemove, mockDockerGetContainer, mockDockerGetImage, + mockDockerPull, + mockFollowProgress, mockImageInspect, mockLoggerWarn, mockRegisterSnapshot, @@ -15,6 +17,8 @@ const { mockContainerRemove: vi.fn(), mockDockerGetContainer: vi.fn(), mockDockerGetImage: vi.fn(), + mockDockerPull: vi.fn(), + mockFollowProgress: vi.fn(), mockImageInspect: vi.fn(), mockLoggerWarn: vi.fn(), mockRegisterSnapshot: vi.fn(), @@ -24,6 +28,8 @@ vi.mock('dockerode', () => ({ default: vi.fn().mockImplementation(() => ({ getContainer: mockDockerGetContainer, getImage: mockDockerGetImage, + pull: mockDockerPull, + modem: { followProgress: mockFollowProgress }, })), })); @@ -46,6 +52,7 @@ import { buildWorkerSnapshotImageName, commitWorkerSnapshot, isImageNotFoundError, + pullImageOnce, removeWorkerContainerBestEffort, } from '../../../src/router/worker-snapshots.js'; @@ -158,3 +165,64 @@ describe('worker-snapshots', () => { ); }); }); + +// Spec: pullImageOnce backs the spawn self-heal in container-manager.ts. +// Single-flight + timeout are non-negotiable: without the in-flight cache, +// every queued job under a missing-image outage races its own multi-GB pull. +describe('pullImageOnce', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDockerPull.mockResolvedValue({} as never); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(null)) as never); + }); + + it('resolves when the pull stream completes without error', async () => { + await expect(pullImageOnce('img:latest')).resolves.toBeUndefined(); + expect(mockDockerPull).toHaveBeenCalledWith('img:latest'); + expect(mockFollowProgress).toHaveBeenCalledTimes(1); + }); + + it('rejects when the pull stream emits an error', async () => { + const err = new Error('manifest denied'); + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => + cb(err)) as never); + await expect(pullImageOnce('img:latest')).rejects.toThrow('manifest denied'); + }); + + it('rejects with a pull-timeout error when the stream never completes', async () => { + mockFollowProgress.mockImplementation((() => { + // Never invoke the callback — exercise the timeout race. + }) as never); + await expect(pullImageOnce('img:latest', 30)).rejects.toThrow(/pull timeout after 30ms/); + }); + + it('deduplicates concurrent calls for the same image (single-flight)', async () => { + let fire!: () => void; + mockFollowProgress.mockImplementation(((_stream: unknown, cb: (err: Error | null) => void) => { + fire = () => cb(null); + }) as never); + const p1 = pullImageOnce('img:latest'); + const p2 = pullImageOnce('img:latest'); + // pullImageOnce awaits docker.pull before reaching followProgress; flush + // microtasks so the deferred-fire callback is captured before we trigger it. + await new Promise((r) => setTimeout(r, 0)); + fire(); + await Promise.all([p1, p2]); + expect(mockDockerPull).toHaveBeenCalledTimes(1); + expect(mockFollowProgress).toHaveBeenCalledTimes(1); + }); + + it('does NOT deduplicate calls for different images', async () => { + await Promise.all([pullImageOnce('a:latest'), pullImageOnce('b:latest')]); + expect(mockDockerPull).toHaveBeenCalledTimes(2); + expect(mockDockerPull).toHaveBeenNthCalledWith(1, 'a:latest'); + expect(mockDockerPull).toHaveBeenNthCalledWith(2, 'b:latest'); + }); + + it('clears the in-flight cache after settling so the next call pulls fresh', async () => { + await pullImageOnce('img:latest'); + await pullImageOnce('img:latest'); + expect(mockDockerPull).toHaveBeenCalledTimes(2); + }); +}); From 8a385405cf9c24b910e0b6a81899fb027972bd52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:55:40 +0200 Subject: [PATCH 20/25] chore(deps): bump @opentelemetry/core and @sentry/node (#1409) Bumps [@opentelemetry/core](https://github.com/open-telemetry/opentelemetry-js) to 2.8.0 and updates ancestor dependency [@sentry/node](https://github.com/getsentry/sentry-javascript). These dependencies need to be updated together. Updates `@opentelemetry/core` from 2.6.1 to 2.8.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.8.0) Updates `@sentry/node` from 10.47.0 to 10.58.0 - [Release notes](https://github.com/getsentry/sentry-javascript/releases) - [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-javascript/compare/10.47.0...10.58.0) --- updated-dependencies: - dependency-name: "@opentelemetry/core" dependency-version: 2.8.0 dependency-type: indirect - dependency-name: "@sentry/node" dependency-version: 10.58.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 674 +++------------------------------------------- package.json | 2 +- 2 files changed, 40 insertions(+), 636 deletions(-) diff --git a/package-lock.json b/package-lock.json index dededa5fc..4a0208a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.14.25", - "@sentry/node": "^10.39.0", + "@sentry/node": "^10.58.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", @@ -1987,72 +1987,6 @@ } } }, - "node_modules/@fastify/otel": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.18.0.tgz", - "integrity": "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.212.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.2.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", - "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", - "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.212.0", - "import-in-the-middle": "^2.0.6", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@google/genai": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", @@ -2731,18 +2665,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", - "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/core": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", @@ -2775,393 +2697,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", - "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", - "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", - "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.62.0.tgz", - "integrity": "sha512-Tvx+vgAZKEQxU3Rx+xWLiR0mLxHwmk69/8ya04+VsV9WYh8w6Lhx5hm5yAMvo1wy0KqWgFKBLwSeo3sHCwdOww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", - "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", - "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", - "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", - "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", - "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz", - "integrity": "sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", - "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", - "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", - "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", - "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", - "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", - "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", - "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", - "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", - "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz", - "integrity": "sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", - "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz", - "integrity": "sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", @@ -3204,21 +2739,6 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -3227,59 +2747,6 @@ "node": ">=14" } }, - "node_modules/@prisma/instrumentation": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", - "integrity": "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3686,54 +3153,29 @@ "license": "MIT" }, "node_modules/@sentry/core": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.47.0.tgz", - "integrity": "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.58.0.tgz", + "integrity": "sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.47.0.tgz", - "integrity": "sha512-R+btqPepv88o635G6HtVewLjqCLUedBg5HBs7Nq1qbbKvyti01uArUF2f+3DsLenk5B9LUNiRlE+frZA44Ahmw==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.58.0.tgz", + "integrity": "sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==", "license": "MIT", "dependencies": { - "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/instrumentation-amqplib": "0.61.0", - "@opentelemetry/instrumentation-connect": "0.57.0", - "@opentelemetry/instrumentation-dataloader": "0.31.0", - "@opentelemetry/instrumentation-express": "0.62.0", - "@opentelemetry/instrumentation-fs": "0.33.0", - "@opentelemetry/instrumentation-generic-pool": "0.57.0", - "@opentelemetry/instrumentation-graphql": "0.62.0", - "@opentelemetry/instrumentation-hapi": "0.60.0", - "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", - "@opentelemetry/instrumentation-kafkajs": "0.23.0", - "@opentelemetry/instrumentation-knex": "0.58.0", - "@opentelemetry/instrumentation-koa": "0.62.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", - "@opentelemetry/instrumentation-mongodb": "0.67.0", - "@opentelemetry/instrumentation-mongoose": "0.60.0", - "@opentelemetry/instrumentation-mysql": "0.60.0", - "@opentelemetry/instrumentation-mysql2": "0.60.0", - "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", - "@opentelemetry/instrumentation-tedious": "0.33.0", - "@opentelemetry/instrumentation-undici": "0.24.0", - "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.6.0", - "@sentry/core": "10.47.0", - "@sentry/node-core": "10.47.0", - "@sentry/opentelemetry": "10.47.0", + "@sentry/core": "10.58.0", + "@sentry/node-core": "10.58.0", + "@sentry/opentelemetry": "10.58.0", + "@sentry/server-utils": "10.58.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3741,13 +3183,13 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.47.0.tgz", - "integrity": "sha512-qv6LsqHbkQmd0aQEUox/svRSz26J+l4gGjFOUNEay2armZu9XLD+Ct89jpFgZD5oIPNAj2jraodTRqydXiwS5w==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.58.0.tgz", + "integrity": "sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.47.0", - "@sentry/opentelemetry": "10.47.0", + "@sentry/core": "10.58.0", + "@sentry/opentelemetry": "10.58.0", "import-in-the-middle": "^3.0.0" }, "engines": { @@ -3755,11 +3197,9 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, @@ -3767,9 +3207,6 @@ "@opentelemetry/api": { "optional": true }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, "@opentelemetry/core": { "optional": true }, @@ -3779,9 +3216,6 @@ "@opentelemetry/instrumentation": { "optional": true }, - "@opentelemetry/resources": { - "optional": true - }, "@opentelemetry/sdk-trace-base": { "optional": true }, @@ -3791,24 +3225,35 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.47.0.tgz", - "integrity": "sha512-f6Hw2lrpCjlOksiosP0Z2jK/+l+21SIdoNglVeG/sttMyx8C8ywONKh0Ha50sFsvB1VaB8n94RKzzf3hkh9V3g==", + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.58.0.tgz", + "integrity": "sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==", "license": "MIT", "dependencies": { - "@sentry/core": "10.47.0" + "@sentry/core": "10.58.0" }, "engines": { "node": ">=18" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, + "node_modules/@sentry/server-utils": { + "version": "10.58.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.58.0.tgz", + "integrity": "sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.58.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -3984,15 +3429,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4040,15 +3476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -4062,6 +3489,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4069,15 +3497,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4132,15 +3551,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@unblessed/core": { "version": "1.0.0-alpha.23", "license": "MIT", @@ -4360,9 +3770,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7178,12 +6588,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -7616,9 +7020,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz", - "integrity": "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.1.0.tgz", + "integrity": "sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==", "license": "Apache-2.0", "dependencies": { "acorn": "^8.15.0", diff --git a/package.json b/package.json index 025651350..514db2dd7 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.14.25", - "@sentry/node": "^10.39.0", + "@sentry/node": "^10.58.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", From 3d7abe70ac2b23e6442b978bd9982c9bea753648 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 19 Jun 2026 15:43:47 +0200 Subject: [PATCH 21/25] fix(db): support DATABASE_SSL=no-verify for self-signed managed Postgres (#1414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): support DATABASE_SSL=no-verify for self-signed managed Postgres Supabase's connection pooler (and similar managed Postgres) REQUIRES TLS but presents a self-signed / private-CA certificate. cascade's SSL config only supported `false` (no TLS) or strict verification (rejectUnauthorized: true) — neither can connect to such an endpoint. A CA file isn't a workable alternative because the router forwards DATABASE_* env to spawned worker containers without mounting any cert file (src/router/worker-container-launcher.ts), so DATABASE_CA_CERT would point at a nonexistent path inside every worker. Add a `no-verify` mode (TLS without certificate verification) and extract the SSL decision into one shared helper (src/db/ssl-config.ts) used by both the runtime client and drizzle-kit migrations, so they connect identically. - DATABASE_SSL=false → no TLS (unchanged) - DATABASE_SSL=no-verify → TLS, rejectUnauthorized:false (new) - otherwise → TLS + verify (+ optional DATABASE_CA_CERT) (unchanged) Tests: full unit coverage of every mode (tests/unit/db/ssl-config.test.ts) plus a no-verify wiring assertion through getDb() (tests/unit/db/client.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(db): route migrate-hooks SSL through shared resolver + document no-verify Address PR review (codex/nhopeatall): - tools/migrate-hooks.ts had its own getSslConfig() returning rejectUnauthorized: true, so the `migrate-hooks.ts --apply` data migration (referenced by 0025_integration_hooks.sql) still failed against a self-signed managed Postgres even with DATABASE_SSL=no-verify. Route it through the shared resolveDbSslConfig() so every DB connection path honors the same modes. This makes ssl-config.ts the true single source of truth. - Document DATABASE_SSL=no-verify in .env.example and CLAUDE.md alongside false / DATABASE_CA_CERT. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(db): make drizzle-kit migrate honor no-verify via sslmode in URL drizzle-kit connects via dbCredentials.url but IGNORES a dbCredentials.ssl object when a url is set, so the earlier `ssl: resolveDbSslConfig()` had no effect on `drizzle-kit migrate` — it kept connecting with strict verification and failing on Supabase's self-signed pooler cert (verified live). Encode the SSL intent as a libpq `sslmode` query param on the migrate URL instead, which drizzle-kit honors: no-verify → sslmode=no-verify, false → sslmode=disable. Add applyDbSslModeToUrl() to the shared ssl-config.ts (derived from the same DATABASE_SSL env as resolveDbSslConfig) with full unit coverage. Verified against a live Supabase pooler: `drizzle-kit migrate` now applies all migrations successfully. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .env.example | 9 ++- CLAUDE.md | 2 +- drizzle.config.ts | 8 ++- src/db/client.ts | 19 +----- src/db/ssl-config.ts | 63 +++++++++++++++++++ tests/unit/db/client.test.ts | 14 +++++ tests/unit/db/ssl-config.test.ts | 102 +++++++++++++++++++++++++++++++ tools/migrate-hooks.ts | 19 +----- 8 files changed, 199 insertions(+), 37 deletions(-) create mode 100644 src/db/ssl-config.ts create mode 100644 tests/unit/db/ssl-config.test.ts diff --git a/.env.example b/.env.example index c43596864..4442b4003 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,15 @@ REDIS_URL=redis://localhost:6379 PORT=3000 LOG_LEVEL=info -# Disable SSL for local PostgreSQL (set to false for local dev without SSL) +# PostgreSQL TLS mode: +# false → no TLS (local dev without SSL) +# no-verify → TLS without certificate verification — for managed Postgres that +# requires TLS but presents a self-signed/private-CA cert (e.g. Supabase's +# connection pooler). DATABASE_CA_CERT does NOT help here because spawned +# worker containers receive DATABASE_* env but no mounted cert file. +# (unset) → TLS with verification; optionally pin a CA via DATABASE_CA_CERT. # DATABASE_SSL=false +# DATABASE_CA_CERT=/path/to/ca.pem # --- Optional: Security --- diff --git a/CLAUDE.md b/CLAUDE.md index 5e0b16e70..2bcf50104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ Required: Optional: -- `DATABASE_SSL=false` to disable SSL locally; `DATABASE_CA_CERT` for managed DBs with a private CA. +- `DATABASE_SSL` — `false` disables SSL (local dev); `no-verify` keeps TLS but skips certificate verification — required for managed Postgres that requires TLS yet presents a self-signed/private-CA cert (e.g. Supabase's connection pooler), where `DATABASE_CA_CERT` can't help because spawned worker containers get `DATABASE_*` env but no mounted cert file; unset → TLS with verification. `DATABASE_CA_CERT` pins a CA for managed DBs with a private CA (verification mode only). - `CREDENTIAL_MASTER_KEY` — 64-char hex (AES-256 key) to encrypt project credentials at rest. Without it, credentials are stored as plaintext; both modes coexist. - `GITHUB_WEBHOOK_SECRET` — opt-in HMAC verification; store as the `webhook_secret` role on the GitHub SCM integration. - `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `SENTRY_TRACES_SAMPLE_RATE` — observability. diff --git a/drizzle.config.ts b/drizzle.config.ts index cddc5c129..707bf3662 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,10 @@ import { defineConfig } from 'drizzle-kit'; +// drizzle-kit connects via the `url` below and IGNORES a `dbCredentials.ssl` object +// when a `url` is set, so the SSL intent must be encoded in the connection string as +// `sslmode`. `applyDbSslModeToUrl` derives it from DATABASE_SSL (shared with the runtime +// client's resolver), letting `DATABASE_SSL=no-verify` work against managed Postgres with +// self-signed certs (e.g. Supabase's pooler), which strict verification would reject. +import { applyDbSslModeToUrl } from './src/db/ssl-config'; export default defineConfig({ schema: [ @@ -14,6 +20,6 @@ export default defineConfig({ out: './src/db/migrations', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL ?? '', + url: applyDbSslModeToUrl(process.env.DATABASE_URL ?? ''), }, }); diff --git a/src/db/client.ts b/src/db/client.ts index 1facb85d5..54e989b3b 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,7 +1,7 @@ -import fs, { existsSync } from 'node:fs'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema/index.js'; +import { resolveDbSslConfig } from './ssl-config.js'; // ============================================================================ // DatabaseContext class @@ -47,7 +47,7 @@ export class DatabaseContext { export function createDatabaseContext(): DatabaseContext { return new DatabaseContext({ connectionString: getDatabaseUrl(), - ssl: getSslConfig(), + ssl: resolveDbSslConfig(), }); } @@ -134,18 +134,3 @@ function getDatabaseUrl(): string { throw new Error('DATABASE_URL or CASCADE_POSTGRES_HOST must be set'); } - -function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { - if (process.env.DATABASE_SSL === 'false') { - return false; - } - const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; - if (process.env.DATABASE_CA_CERT) { - const certPath = process.env.DATABASE_CA_CERT; - if (!existsSync(certPath)) { - throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); - } - sslConfig.ca = fs.readFileSync(certPath, 'utf8'); - } - return sslConfig; -} diff --git a/src/db/ssl-config.ts b/src/db/ssl-config.ts new file mode 100644 index 000000000..a6ab0c80e --- /dev/null +++ b/src/db/ssl-config.ts @@ -0,0 +1,63 @@ +import fs, { existsSync } from 'node:fs'; + +export type DbSslConfig = false | { rejectUnauthorized: boolean; ca?: string }; + +/** + * Resolve node-postgres SSL options from DATABASE_SSL / DATABASE_CA_CERT. + * + * Modes (DATABASE_SSL): + * - `'false'` → no TLS (local dev, or networks that terminate TLS elsewhere). + * - `'no-verify'` → TLS, but skip certificate verification. Required for managed + * Postgres that REQUIRES TLS yet presents a self-signed / private-CA + * certificate (e.g. Supabase's connection pooler). A CA file is not a + * workable alternative there: spawned worker containers receive the + * `DATABASE_*` env but no mounted cert file (see + * `src/router/worker-container-launcher.ts`), so `DATABASE_CA_CERT` + * would point at a nonexistent path inside every worker. + * - anything else → TLS WITH verification, plus an optional CA from `DATABASE_CA_CERT`. + * + * Single source of truth shared by the runtime DB client (`src/db/client.ts`) and + * drizzle-kit migrations (`drizzle.config.ts`) so both connect identically. + */ +export function resolveDbSslConfig(): DbSslConfig { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + if (process.env.DATABASE_SSL === 'no-verify') { + return { rejectUnauthorized: false }; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = fs.readFileSync(certPath, 'utf8'); + } + return sslConfig; +} + +/** + * Encode the `DATABASE_SSL` intent as a libpq `sslmode` query param on a connection URL. + * + * Needed for **drizzle-kit migrations only**: drizzle-kit connects via the `url` in + * `drizzle.config.ts` but ignores a `dbCredentials.ssl` object when a `url` is set, so + * `resolveDbSslConfig()` can't reach it — the SSL mode has to live in the connection + * string instead. (The runtime client and the data-migration tools pass the resolved + * `ssl` object directly, where the object form is honored.) + * + * - `DATABASE_SSL=no-verify` → append `sslmode=no-verify` (TLS, skip cert verification) + * - `DATABASE_SSL=false` → append `sslmode=disable` (no TLS) + * - otherwise → URL unchanged (driver default; verification not forced + * here to preserve existing local-dev behavior) + * + * No-ops on an empty URL or one that already pins an `sslmode`. + */ +export function applyDbSslModeToUrl(url: string): string { + const mode = process.env.DATABASE_SSL; + const sslmode = mode === 'false' ? 'disable' : mode === 'no-verify' ? 'no-verify' : undefined; + if (!url || !sslmode || /[?&]sslmode=/.test(url)) { + return url; + } + return `${url}${url.includes('?') ? '&' : '?'}sslmode=${sslmode}`; +} diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index 63f22275e..3413bb9eb 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -309,6 +309,20 @@ describe('getDb', () => { expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); }); + it('creates pool with rejectUnauthorized:false when DATABASE_SSL=no-verify', () => { + // Managed Postgres that requires TLS but presents a self-signed cert + // (e.g. Supabase's connection pooler). No CA file is read. + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ ssl: { rejectUnauthorized: false } }), + ); + }); + it('returns singleton — second call returns same instance', () => { const first = getDb(); const second = getDb(); diff --git a/tests/unit/db/ssl-config.test.ts b/tests/unit/db/ssl-config.test.ts new file mode 100644 index 000000000..a0f54eb05 --- /dev/null +++ b/tests/unit/db/ssl-config.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted fs mock ─────────────────────────────────────────────────────────── +const { mockReadFileSync, mockExistsSync } = vi.hoisted(() => ({ + mockReadFileSync: vi.fn().mockReturnValue('mock-ca-cert-content'), + mockExistsSync: vi.fn().mockReturnValue(true), +})); + +vi.mock('node:fs', () => ({ + default: { readFileSync: mockReadFileSync }, + existsSync: mockExistsSync, +})); + +import { applyDbSslModeToUrl, resolveDbSslConfig } from '../../../src/db/ssl-config.js'; + +describe('resolveDbSslConfig', () => { + afterEach(() => { + vi.unstubAllEnvs(); + mockReadFileSync.mockClear(); + mockExistsSync.mockClear(); + mockExistsSync.mockReturnValue(true); + }); + + it('returns false when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + expect(resolveDbSslConfig()).toBe(false); + }); + + it('returns { rejectUnauthorized: false } when DATABASE_SSL=no-verify', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', ''); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: false }); + }); + + it('ignores DATABASE_CA_CERT in no-verify mode (no file read)', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: false }); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('returns { rejectUnauthorized: true } by default (DATABASE_SSL unset)', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', ''); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: true }); + }); + + it('attaches CA cert when DATABASE_CA_CERT is set (verify mode)', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + expect(resolveDbSslConfig()).toEqual({ rejectUnauthorized: true, ca: 'mock-ca-cert-content' }); + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/ca.pem', 'utf8'); + }); + + it('throws a descriptive error when DATABASE_CA_CERT path does not exist', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/nonexistent/ca.pem'); + mockExistsSync.mockReturnValueOnce(false); + expect(() => resolveDbSslConfig()).toThrow( + 'DATABASE_CA_CERT file not found: /nonexistent/ca.pem', + ); + }); +}); + +describe('applyDbSslModeToUrl', () => { + afterEach(() => vi.unstubAllEnvs()); + + const URL = 'postgresql://u:p@host:5432/db'; + + it('appends sslmode=no-verify when DATABASE_SSL=no-verify', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl(URL)).toBe(`${URL}?sslmode=no-verify`); + }); + + it('appends sslmode=disable when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + expect(applyDbSslModeToUrl(URL)).toBe(`${URL}?sslmode=disable`); + }); + + it('leaves the URL unchanged when DATABASE_SSL is unset (verify mode)', () => { + vi.stubEnv('DATABASE_SSL', ''); + expect(applyDbSslModeToUrl(URL)).toBe(URL); + }); + + it('uses & when the URL already has a query string', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl(`${URL}?application_name=x`)).toBe( + `${URL}?application_name=x&sslmode=no-verify`, + ); + }); + + it('does not override an sslmode already present in the URL', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + const withMode = `${URL}?sslmode=require`; + expect(applyDbSslModeToUrl(withMode)).toBe(withMode); + }); + + it('returns an empty URL unchanged', () => { + vi.stubEnv('DATABASE_SSL', 'no-verify'); + expect(applyDbSslModeToUrl('')).toBe(''); + }); +}); diff --git a/tools/migrate-hooks.ts b/tools/migrate-hooks.ts index 6d35549cf..49c16db58 100644 --- a/tools/migrate-hooks.ts +++ b/tools/migrate-hooks.ts @@ -8,8 +8,8 @@ * npx tsx tools/migrate-hooks.ts --apply # Apply changes */ -import { existsSync, readFileSync } from 'node:fs'; import pg from 'pg'; +import { resolveDbSslConfig } from '../src/db/ssl-config.js'; const DATABASE_URL = process.env.DATABASE_URL ?? ''; if (!DATABASE_URL) { @@ -17,21 +17,6 @@ if (!DATABASE_URL) { process.exit(1); } -function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { - if (process.env.DATABASE_SSL === 'false') { - return false; - } - const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; - if (process.env.DATABASE_CA_CERT) { - const certPath = process.env.DATABASE_CA_CERT; - if (!existsSync(certPath)) { - throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); - } - sslConfig.ca = readFileSync(certPath, 'utf8'); - } - return sslConfig; -} - const dryRun = !process.argv.includes('--apply'); interface LegacyBackend { @@ -140,7 +125,7 @@ async function main() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2, - ssl: getSslConfig(), + ssl: resolveDbSslConfig(), }); try { From 3b5d0daf8d585e77b49b29058ff5e2cd524cceef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:44:09 +0200 Subject: [PATCH 22/25] chore(deps): bump js-yaml from 4.1.1 to 4.2.0 (#1411) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/commits) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.2.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 18 ++++++++++++++---- package.json | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a0208a9d..bb6c034f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "fastest-levenshtein": "^1.0.16", "hono": "^4.12.25", "jira.js": "^5.3.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", @@ -7416,9 +7416,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 514db2dd7..027fdf47e 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "fastest-levenshtein": "^1.0.16", "hono": "^4.12.25", "jira.js": "^5.3.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", From 823ea0a6d77735b9fa3e9618f5d6cdac0255c54d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:44:21 +0200 Subject: [PATCH 23/25] chore(deps): bump undici from 7.24.1 to 7.28.0 (#1412) Bumps [undici](https://github.com/nodejs/undici) from 7.24.1 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.24.1...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb6c034f8..23dea23ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10121,9 +10121,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { From 22899dd50b73a546269f7c4a6e070623491b1696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 19 Jun 2026 15:52:50 +0200 Subject: [PATCH 24/25] feat: add user profile page and password change functionality (#1407) * feat: add user profile page and password change functionality * build: run npm audit fix to resolve vulnerabilities in dependencies --------- Co-authored-by: Zbigniew Sobiecki --- src/api/routers/auth.ts | 15 +++ src/api/trpc.ts | 7 +- src/dashboard.ts | 4 +- tests/unit/api/routers/auth.test.ts | 44 +++++++- web/src/components/layout/header.tsx | 9 +- web/src/components/layout/sidebar.tsx | 9 +- web/src/routes/route-tree.ts | 2 + web/src/routes/settings/profile.tsx | 146 ++++++++++++++++++++++++++ 8 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 web/src/routes/settings/profile.tsx diff --git a/src/api/routers/auth.ts b/src/api/routers/auth.ts index 52291f040..d951efcc3 100644 --- a/src/api/routers/auth.ts +++ b/src/api/routers/auth.ts @@ -1,4 +1,7 @@ +import bcrypt from 'bcrypt'; +import { z } from 'zod'; import { getOrganization, listAllOrganizations } from '../../db/repositories/settingsRepository.js'; +import { deleteUserSessions, updateUser } from '../../db/repositories/usersRepository.js'; import { protectedProcedure, router } from '../trpc.js'; export const authRouter = router({ @@ -19,4 +22,16 @@ export const authRouter = router({ } return { ...base, availableOrgs: undefined as { id: string; name: string }[] | undefined }; }), + + changePassword: protectedProcedure + .input( + z.object({ + password: z.string().min(12), + }), + ) + .mutation(async ({ ctx, input }) => { + const passwordHash = await bcrypt.hash(input.password, 10); + await updateUser(ctx.user.id, { passwordHash }); + await deleteUserSessions(ctx.user.id, ctx.token || undefined); + }), }); diff --git a/src/api/trpc.ts b/src/api/trpc.ts index 66d4dbdd5..28874b818 100644 --- a/src/api/trpc.ts +++ b/src/api/trpc.ts @@ -12,6 +12,7 @@ export interface TRPCUser { export interface TRPCContext { user: TRPCUser | null; effectiveOrgId: string | null; + token: string | null; } const t = initTRPC.context().create({ @@ -34,7 +35,11 @@ export const protectedProcedure = t.procedure.use(async (opts) => { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return opts.next({ - ctx: { user: opts.ctx.user, effectiveOrgId: opts.ctx.effectiveOrgId }, + ctx: { + user: opts.ctx.user, + effectiveOrgId: opts.ctx.effectiveOrgId, + token: opts.ctx.token, + }, }); }); diff --git a/src/dashboard.ts b/src/dashboard.ts index 20f78948b..148dec2c7 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -77,10 +77,10 @@ app.use( endpoint: '/trpc', router: appRouter, createContext: async (_opts, c) => { - const token = getCookie(c, SESSION_COOKIE_NAME); + const token = getCookie(c, SESSION_COOKIE_NAME) || null; const user = token ? await resolveUserFromSession(token) : null; const effectiveOrgId = await computeEffectiveOrgId(user, c.req.header('x-org-context')); - return { user, effectiveOrgId }; + return { user, effectiveOrgId, token }; }, }), ); diff --git a/tests/unit/api/routers/auth.test.ts b/tests/unit/api/routers/auth.test.ts index d91a23e3f..6ed4d9ed7 100644 --- a/tests/unit/api/routers/auth.test.ts +++ b/tests/unit/api/routers/auth.test.ts @@ -2,16 +2,24 @@ import { describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockListAllOrganizations, mockGetOrganization } = vi.hoisted(() => ({ - mockListAllOrganizations: vi.fn(), - mockGetOrganization: vi.fn(), -})); +const { mockListAllOrganizations, mockGetOrganization, mockUpdateUser, mockDeleteUserSessions } = + vi.hoisted(() => ({ + mockListAllOrganizations: vi.fn(), + mockGetOrganization: vi.fn(), + mockUpdateUser: vi.fn(), + mockDeleteUserSessions: vi.fn(), + })); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listAllOrganizations: mockListAllOrganizations, getOrganization: mockGetOrganization, })); +vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ + updateUser: mockUpdateUser, + deleteUserSessions: mockDeleteUserSessions, +})); + import { authRouter } from '../../../../src/api/routers/auth.js'; const createCaller = createCallerFor(authRouter); @@ -62,8 +70,34 @@ describe('authRouter', () => { }); it('throws UNAUTHORIZED when not authenticated', async () => { - const caller = createCaller({ user: null, effectiveOrgId: null }); + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); await expectTRPCError(caller.me(), 'UNAUTHORIZED'); }); }); + + describe('changePassword', () => { + it('hashes password, updates user, and deletes other sessions', async () => { + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: 'current-session-token', + }); + + await caller.changePassword({ password: 'new-secure-password-123' }); + + expect(mockUpdateUser).toHaveBeenCalledWith(mockUser.id, { + passwordHash: expect.any(String), + }); + expect(mockDeleteUserSessions).toHaveBeenCalledWith(mockUser.id, 'current-session-token'); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); + await expectTRPCError( + caller.changePassword({ password: 'new-secure-password-123' }), + 'UNAUTHORIZED', + ); + }); + }); }); diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx index a9b1e790f..a72907e0f 100644 --- a/web/src/components/layout/header.tsx +++ b/web/src/components/layout/header.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { LogOut, Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { type ReactNode, useEffect, useState } from 'react'; @@ -41,7 +41,12 @@ export function Header({ user, mobileMenuTrigger }: HeaderProps) {
{user && ( - {user.name} + + {user.name} + )} {mounted && (
); diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 8e67d0241..509514a29 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -17,6 +17,7 @@ import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; import { settingsGeneralRoute } from './settings/general.js'; +import { settingsProfileRoute } from './settings/profile.js'; import { settingsUsersRoute } from './settings/users.js'; import { workItemRunsRoute } from './work-items/$projectId.$workItemId.js'; @@ -35,6 +36,7 @@ export const routeTree = rootRoute.addChildren([ projectLifecycleRoute, ]), settingsGeneralRoute, + settingsProfileRoute, settingsUsersRoute, globalDefinitionsRoute, globalWebhookLogsRoute, diff --git a/web/src/routes/settings/profile.tsx b/web/src/routes/settings/profile.tsx new file mode 100644 index 000000000..1f83d95cb --- /dev/null +++ b/web/src/routes/settings/profile.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { rootRoute } from '../__root.js'; + +function ProfilePage() { + const meQuery = useQuery(trpc.auth.me.queryOptions()); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const changePasswordMutation = useMutation({ + mutationFn: (data: { password: string }) => trpcClient.auth.changePassword.mutate(data), + onSuccess: () => { + toast.success('Password changed successfully'); + setPassword(''); + setConfirmPassword(''); + }, + onError: (error) => { + toast.error('Failed to change password', { description: error.message }); + }, + }); + + if (meQuery.isLoading) { + return
Loading profile...
; + } + + if (!meQuery.data) { + return
Failed to load profile.
; + } + + const user = meQuery.data; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + toast.error('Passwords do not match'); + return; + } + if (password.length < 12) { + toast.error('Password must be at least 12 characters'); + return; + } + changePasswordMutation.mutate({ password }); + }; + + return ( +
+
+

User Profile

+

+ Manage your account settings and change your password. +

+
+ +
+ {/* Account Information Card */} + + + Account Information + Overview of your account details. + + +
+ Name + {user.name} +
+
+ Email + {user.email} +
+
+ Role + + {user.role} + +
+
+ Organization + + {user.orgName || 'N/A'} + +
+
+
+ + {/* Change Password Card */} + + + Change Password + + Set a new password for your account. Minimum 12 characters. + + + +
+
+ + setPassword(e.target.value)} + placeholder="Minimum 12 characters" + minLength={12} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + minLength={12} + required + /> +
+
+ +
+
+
+
+
+
+ ); +} + +export const settingsProfileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/profile', + component: ProfilePage, +}); From f7c2c0ba940ac6ac4cb79b00558166dc4cc1b1c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:05:41 +0200 Subject: [PATCH 25/25] chore(deps-dev): bump @babel/core from 7.29.0 to 7.29.7 in /web (#1413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps-dev): bump @babel/core from 7.29.0 to 7.29.7 in /web Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.29.0 to 7.29.7. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core) --- updated-dependencies: - dependency-name: "@babel/core" dependency-version: 7.29.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] * chore(web): sync web lockfile so npm ci passes dependabot's @babel/core 7.29.7 bump only updated its own lockfile entries, leaving web/package-lock.json stale against the @trpc/server@11.16.0 / react-is@19.2.7 that dev added after this branch was cut — so `npm ci` failed the "Install web dependencies" CI step. Regenerated the web lockfile against current dev: keeps the intended @babel/core 7.29.7, adds the missing deps, and refreshes browserslist data tables. No runtime dependency upgrades. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zbigniew Sobiecki Co-authored-by: Claude Opus 4.8 (1M context) --- web/package-lock.json | 282 ++++++++++++++++++++++++++---------------- 1 file changed, 176 insertions(+), 106 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 711660c00..b26b3dc90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -39,13 +39,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -54,9 +54,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -64,21 +64,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -95,14 +95,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -112,14 +112,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -129,9 +129,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -139,29 +139,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -181,9 +181,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -191,9 +191,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -201,9 +201,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -211,27 +211,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -273,33 +273,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -307,14 +307,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2988,6 +2988,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -3449,19 +3513,22 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3479,11 +3546,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3493,9 +3560,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -3837,9 +3904,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -5114,11 +5181,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/parse-entities": { "version": "4.0.2", @@ -5332,9 +5402,9 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT", "peer": true },