From a020eb36637ebe6f0185d401c28ac0abd44641a2 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Tue, 12 May 2026 11:33:16 +0000 Subject: [PATCH 1/2] fix(linear): replace visibility-poll with in-process cache (MNG-741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visibility-poll added in commit fad4dda1 throws "Linear description visibility timed out for issue X after 1000ms" whenever Linear's read replica is slower than 1s — which is routine under load. Three planning runs failed back-to-back on prod (2026-05-12) on this signal: MNG-741 run 44c1bc3f 11m "no PM write recorded" (visibility throw propagated up; CLI exited; sidecar never written; gate failed) MNG-736 run 98ae7010 13m same MNG-739 run 1534ab74 16m same The 1s wait was always advisory — it only guarded against in-process consecutive `updateDescription` calls reading a stale GET between PUTs. The new contract removes the wait entirely and adds an in-process recent-description cache (60s TTL, keyed on issueId). After each successful PUT, the cache stores the new description. The next `updateDescription` call consults the cache before mutating — if GET returned a stale pre-PUT value (Linear's eventual-consistency window), the cached fresh value wins. This gives the same in-process read-after-write guarantee the wait was supposed to provide, without throwing on slow Linear days. The wait never solved cross-process consistency (the existing withDescriptionMutationLock is process-local too); the cache doesn't either, but that scope was never claimed. TDD test pins the worst case: Linear GET continues to return the stale pre-PUT description forever; createChecklist + addChecklistItem must still produce a correct PUT containing the appended item via the cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pm/linear/adapter.ts | 100 +++++++++++++++++---------- tests/unit/pm/linear/adapter.test.ts | 50 +++++++++++++- 2 files changed, 112 insertions(+), 38 deletions(-) diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index df4c791c..6356d5c8 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -38,8 +38,60 @@ import type { WorkItemLabel, } from '../types.js'; -const DESCRIPTION_VISIBILITY_TIMEOUT_MS = 1_000; -const DESCRIPTION_VISIBILITY_POLL_MS = 25; +/** + * In-process read-after-write cache of recently-PUT issue descriptions. + * + * Linear's API is eventually consistent — a GET issued moments after a PUT + * can return the previous description for seconds. Polling the GET until + * visibility (the prior approach in commit fad4dda1) was too aggressive + * (1s timeout) and DOSed itself: planning runs MNG-741 / MNG-736 / MNG-739 + * (2026-05-12) all failed with "Linear description visibility timed out" + * even though every PUT succeeded on Linear's side. + * + * The new contract: after each successful PUT, store the new description + * here. The next `updateDescription` call consults the cache before + * mutating — if the GET returned a stale value within the consistency + * window, the cached value wins. After TTL the entry is evicted and the + * GET becomes authoritative again. + * + * Scope: in-process only. Cross-process races against Linear's eventual + * consistency are NOT new to this fix and were never solved by the + * visibility wait — the existing `withDescriptionMutationLock` is + * process-local too. + */ +const RECENT_DESCRIPTION_TTL_MS = 60_000; +type RecentDescription = { description: string; timestamp: number }; +const recentDescriptions = new Map(); + +function rememberRecentDescription(issueId: string, description: string): void { + recentDescriptions.set(issueId, { description, timestamp: Date.now() }); + // Lazy cleanup — keep the map small without a setInterval. + if (recentDescriptions.size > 200) { + const cutoff = Date.now() - RECENT_DESCRIPTION_TTL_MS; + for (const [id, entry] of recentDescriptions.entries()) { + if (entry.timestamp < cutoff) recentDescriptions.delete(id); + } + } +} + +function recallRecentDescription(issueId: string): string | undefined { + const entry = recentDescriptions.get(issueId); + if (!entry) return undefined; + if (Date.now() - entry.timestamp > RECENT_DESCRIPTION_TTL_MS) { + recentDescriptions.delete(issueId); + return undefined; + } + return entry.description; +} + +/** + * Test-only escape hatch — each test starts with an empty cache so module- + * level state doesn't leak between cases. NOT exported via the public API; + * called only from `tests/unit/pm/linear/adapter.test.ts`. + */ +export function __resetRecentDescriptionsForTests(): void { + recentDescriptions.clear(); +} const CASCADE_STATUS_KEYS = new Set([ 'backlog', 'todo', @@ -341,10 +393,18 @@ export class LinearPMProvider implements PMProvider { throw err; } - const newDesc = mutate(issue.description ?? ''); + // Read-after-write consistency: if we recently PUT a newer description + // for this issue and the GET above returned the stale pre-PUT value + // (Linear's eventual-consistency window), use the cached fresh value + // as the source of truth for the mutation. Without this, consecutive + // in-process updates can read-modify-write over each other. + const cachedDescription = recallRecentDescription(issueId); + const baseDescription = + cachedDescription !== undefined ? cachedDescription : (issue.description ?? ''); + const newDesc = mutate(baseDescription); try { await linearClient.updateIssue(issueId, { description: newDesc }); - await this.waitForDescriptionVisibility(issueId, newDesc); + rememberRecentDescription(issueId, newDesc); return; } catch (err) { if (attempt === 0) { @@ -356,30 +416,6 @@ export class LinearPMProvider implements PMProvider { } } - private async waitForDescriptionVisibility( - issueId: string, - expectedDescription: string, - ): Promise { - const startedAt = Date.now(); - const deadline = startedAt + DESCRIPTION_VISIBILITY_TIMEOUT_MS; - const expected = normalizeDescriptionForVisibility(expectedDescription); - - while (true) { - const issue = await linearClient.getIssue(issueId); - if (normalizeDescriptionForVisibility(issue.description ?? '') === expected) { - return; - } - - if (Date.now() >= deadline) { - throw new Error( - `Linear description visibility timed out for issue ${issueId} after ${DESCRIPTION_VISIBILITY_TIMEOUT_MS}ms`, - ); - } - - await sleep(Math.min(DESCRIPTION_VISIBILITY_POLL_MS, Math.max(0, deadline - Date.now()))); - } - } - private logDescriptionRetry(issueId: string, err: unknown): void { logger.warn('[Linear] Description provider update failed; retrying once', { issueId, @@ -455,11 +491,3 @@ export class LinearPMProvider implements PMProvider { }; } } - -function normalizeDescriptionForVisibility(description: string): string { - return description.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts index 7bae68fd..e6cf2eb8 100644 --- a/tests/unit/pm/linear/adapter.test.ts +++ b/tests/unit/pm/linear/adapter.test.ts @@ -36,7 +36,10 @@ vi.mock('../../../../src/linear/client.js', () => ({ }, })); -import { LinearPMProvider } from '../../../../src/pm/linear/adapter.js'; +import { + __resetRecentDescriptionsForTests, + LinearPMProvider, +} from '../../../../src/pm/linear/adapter.js'; // --------------------------------------------------------------------------- // Helpers @@ -100,6 +103,7 @@ describe('LinearPMProvider', () => { beforeEach(() => { provider = new LinearPMProvider(defaultConfig); vi.clearAllMocks(); + __resetRecentDescriptionsForTests(); }); it('has type "linear"', () => { @@ -559,7 +563,13 @@ describe('LinearPMProvider', () => { expect(result.items[0].id).toMatch(/^cl-[0-9a-f]{8}$/); }); - it('waits for Linear read-after-write visibility before the next checklist append', async () => { + // MNG-741 regression (2026-05-12): the original fix at commit fad4dda1 + // polled Linear's GET after every PUT with a 1s deadline, then THREW + // on timeout. Linear's eventual-consistency window is routinely >1s + // under load — every planning run hit this and got bounced. The new + // contract: skip the visibility poll entirely, and provide read-after- + // write consistency via an in-process recent-description cache. + it('uses recent-description cache instead of polling Linear after a PUT', async () => { let description = 'Existing.'; let staleDescription: string | null = null; mockGetIssue.mockImplementation(async () => { @@ -582,6 +592,42 @@ describe('LinearPMProvider', () => { expect(description).toBe('Existing.\n\n### ✅ AC\n- [ ] First item'); }); + it('proceeds without throwing when Linear NEVER returns the updated description (MNG-741)', async () => { + // Worst-case path: Linear's GET continues to return the stale description + // forever after the PUT (e.g. the read replica is broken or just very slow + // — Linear's eventual-consistency window can exceed seconds under load). + // Previously this would throw "Linear description visibility timed out" + // after 1s and the agent's `cascade-tools pm add-checklist` invocation + // would exit non-zero, skip the sidecar write, and the run would fail the + // requiresPMWrite gate. Prod incident: MNG-741 / MNG-736 / MNG-739 + // (2026-05-12, run 1ce6ed4a). New contract: PUT succeeded from Linear's + // perspective; trust the cache for subsequent in-process operations, + // never throw on visibility-poll failure. + const persistedDescription = 'Existing.'; + mockGetIssue.mockImplementation(async () => + // IMPORTANT: GET always returns the stale pre-PUT value, simulating + // Linear's read replica being permanently behind for the test. + makeIssue({ description: persistedDescription }), + ); + mockUpdateIssue.mockImplementation(async (_id, _updates: { description?: string }) => + makeIssue({ description: persistedDescription }), + ); + + const checklist = await provider.createChecklist('issue-uuid', '✅ AC'); + expect(checklist.name).toBe('✅ AC'); + + // Subsequent operation finds the checklist via the in-process cache, + // even though GET keeps returning the stale pre-PUT description. + await expect(provider.addChecklistItem(checklist.id, 'First item')).resolves.toBeUndefined(); + + // The last PUT body MUST include the appended item. If the cache wasn't + // used, mutate() would run against the stale GET → no checklist found → + // throw (or, worse, silently overwrite without the section). + const lastPut = mockUpdateIssue.mock.calls[mockUpdateIssue.mock.calls.length - 1]; + expect(lastPut[1].description).toContain('### ✅ AC'); + expect(lastPut[1].description).toContain('- [ ] First item'); + }); + it('preserves multiple concurrently-created checklist sections despite stale Linear reads', async () => { let description = 'Existing.'; let staleDescription: string | null = null; From 4f6b93ad0dc889054c0be69ab2e25b9b9895cc96 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 12 May 2026 14:01:08 +0200 Subject: [PATCH 2/2] fix(scm): align review comment CLI parsing (#1358) Co-authored-by: Cascade Bot --- CHANGELOG.md | 2 + src/gadgets/README.md | 4 +- src/gadgets/github/definitions.ts | 7 + src/gadgets/shared/cli/params.ts | 74 +++++++++- .../backends/shared-nativeToolPrompts.test.ts | 13 +- tests/unit/backends/toolManifests.test.ts | 13 ++ tests/unit/cli/cli-command-factory.test.ts | 91 +++++++++++++ tests/unit/cli/file-input-flags.test.ts | 127 ++++++++++++++++++ tests/unit/gadgets/github/definitions.test.ts | 9 ++ tests/unit/gadgets/shared/cli/params.test.ts | 72 ++++++++++ 10 files changed, 406 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cc1947..085c7ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l ### Fixed +- **`cascade-tools scm reply-to-review-comment` now accepts `--body-file`, and `create-pr-review --comment` handles one inline comment object ergonomically.** `ReplyToReviewComment` now exposes the same generated body file-input contract as the other SCM comment commands. Array-of-object CLI params still prefer JSON arrays, but one top-level JSON object is normalized to a one-item array, while `null` and primitive JSON values fail early with the structured `json-parse` envelope instead of reaching the GitHub client. See Linear issues [MNG-736](https://linear.app/mongrel/issue/MNG-736/fix-cascade-tools-scm-review-command-cli-drift), [MNG-731](https://linear.app/mongrel/issue/MNG-731/friction-tooling-low-cascade-tools-reply-to-review-comment-rejects), and [MNG-729](https://linear.app/mongrel/issue/MNG-729/friction-tooling-low-create-pr-review-comment-accepted-an-object-but). + - **Linear inline checklist creation now waits for description visibility before releasing its mutation lock.** Linear can briefly return stale issue descriptions after accepting `updateIssue()`, so CASCADE now polls `getIssue()` under the existing per-issue description lock until the new markdown is visible. Parallel `AddChecklist` flows no longer fail with `Checklist not found in description` when one flow creates a checklist heading immediately before another appends items. See Linear issue [MNG-685](https://linear.app/issue/MNG-685). - **Linear and JIRA inline checklist updates no longer lose sibling checklist rows during concurrent updates.** Both providers rewrite the whole issue description for checklist mutations, so their read/mutate/write path is now serialized per provider/work item with a stale-safe temp-file lock and provider-only retry semantics. The shared inline checklist parser also keeps scanning through prose, indented detail lines, and bullet detail lines until the next heading, so `ReadWorkItem` reports every visible checkbox row under a checklist heading. See Linear issue [MNG-656](https://linear.app/issue/MNG-656). diff --git a/src/gadgets/README.md b/src/gadgets/README.md index b0848c2d..9e1c4004 100644 --- a/src/gadgets/README.md +++ b/src/gadgets/README.md @@ -40,6 +40,8 @@ comments: { The canonical parameter name always wins. Aliases are additive; suggestions returned by the fuzzy-matcher always point at the canonical form. +For array-of-object CLI params, the canonical input remains a JSON array. The shared parser also accepts one top-level JSON object and normalizes it to a one-element array so aliases like `--comment '{"path":"src/x.ts","line":1,"body":"nit"}'` behave the way agents naturally expect. Arrays pass through unchanged, and the parser validates only the top-level JSON shape; it does not inspect individual array entries. + ### `cli.fileInputAlternatives` Opt-in `---file ` escape hatches for long or JSON-shaped payloads that don't survive shell quoting. Pair with `parseAs: 'json'` for array-of-object / object params so the file contents are `JSON.parse`-d before reaching the gadget. @@ -169,7 +171,7 @@ Two tuning constants live in `src/gadgets/shared/cli/parseErrors.ts`: `MAX_FLAG_ ## Reference: `createPRReviewDef` -`src/gadgets/github/definitions.ts` is the reference gadget for spec 014 — it carries `cliAliases: ['comment']` on `comments`, `fileInputAlternatives` entries for `--body-file` (long review summaries) and `--comments-file` (JSON inline comments), and a well-formed `examples` block. Read it before you add a new gadget with array-of-object parameters. +`src/gadgets/github/definitions.ts` is the reference gadget for spec 014 — it carries `cliAliases: ['comment']` on `comments`, `fileInputAlternatives` entries for `--body-file` (long review summaries, reply bodies, and comment updates) and `--comments-file` (JSON inline comments), and a well-formed `examples` block. Read it before you add a new gadget with array-of-object parameters. ## Reference: `ReportFriction` diff --git a/src/gadgets/github/definitions.ts b/src/gadgets/github/definitions.ts index 43407d03..f5963b76 100644 --- a/src/gadgets/github/definitions.ts +++ b/src/gadgets/github/definitions.ts @@ -610,6 +610,13 @@ export const replyToReviewCommentDef: ToolDefinition = { ], cli: { autoResolved: ownerRepoAutoResolved, + fileInputAlternatives: [ + { + paramName: 'body', + fileFlag: 'body-file', + description: 'Read reply body from file (use - for stdin)', + }, + ], }, }; diff --git a/src/gadgets/shared/cli/params.ts b/src/gadgets/shared/cli/params.ts index 70d07782..970f4322 100644 --- a/src/gadgets/shared/cli/params.ts +++ b/src/gadgets/shared/cli/params.ts @@ -44,6 +44,58 @@ function parseJsonOrError( } } +function describeJsonTopLevel(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function normalizeArrayOfObjectJsonOrError( + parsed: unknown, + flag: string, + paramDef: ParameterDefinition, + fileAlt: FileInputAlternative | undefined, + example: unknown, + sink: ErrorSink, +): unknown { + if (paramDef.type !== 'array' || paramDef.items !== 'object') { + return parsed; + } + + if (Array.isArray(parsed)) { + return parsed; + } + + if (parsed !== null && typeof parsed === 'object') { + return [parsed]; + } + + const fileHint = fileAlt ? ` Or pass --${fileAlt.fileFlag} (or - for stdin).` : ''; + return emitCliError({ + type: 'json-parse', + flag, + message: `Expected JSON array or object, got ${describeJsonTopLevel(parsed)}`, + got: JSON.stringify(parsed), + expected: expectedShapeFor(paramDef, example), + hint: `Pass a JSON array of objects or one JSON object to normalize into a single-item array.${fileHint}`, + stdout: sink.stdout, + stderr: sink.stderr, + exit: sink.exit, + }); +} + +function parseJsonParamOrError( + raw: string, + flag: string, + paramDef: ParameterDefinition, + fileAlt: FileInputAlternative | undefined, + example: unknown, + sink: ErrorSink, +): unknown { + const parsed = parseJsonOrError(raw, flag, paramDef, fileAlt, example, sink); + return normalizeArrayOfObjectJsonOrError(parsed, flag, paramDef, fileAlt, example, sink); +} + function resolveFileInputParam( name: string, paramDef: ParameterDefinition, @@ -59,7 +111,14 @@ function resolveFileInputParam( if (typeof fileFlagValue === 'string' && fileFlagValue.length > 0) { const contents = readFileInput(fileFlagValue); if (fileAlt.parseAs === 'json') { - resolvedParams[name] = parseJsonOrError(contents, name, paramDef, fileAlt, example, sink); + resolvedParams[name] = parseJsonParamOrError( + contents, + name, + paramDef, + fileAlt, + example, + sink, + ); return; } resolvedParams[name] = contents; @@ -69,7 +128,14 @@ function resolveFileInputParam( if (directValue !== undefined && directValue !== null) { if (paramDef.type === 'array' && paramDef.items === 'object') { const asString = typeof directValue === 'string' ? directValue : JSON.stringify(directValue); - resolvedParams[name] = parseJsonOrError(asString, name, paramDef, fileAlt, example, sink); + resolvedParams[name] = parseJsonParamOrError( + asString, + name, + paramDef, + fileAlt, + example, + sink, + ); return; } if (typeof directValue === 'string') { @@ -103,7 +169,7 @@ function resolveObjectParam( if (typeof rawValue !== 'string') { return; } - resolvedParams[name] = parseJsonOrError(rawValue, name, paramDef, undefined, example, sink); + resolvedParams[name] = parseJsonParamOrError(rawValue, name, paramDef, undefined, example, sink); } function resolveArrayOfObjectParam( @@ -118,7 +184,7 @@ function resolveArrayOfObjectParam( const rawValue = flags[name]; if (rawValue === undefined) return; const asString = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue); - resolvedParams[name] = parseJsonOrError(asString, name, paramDef, fileAlt, example, sink); + resolvedParams[name] = parseJsonParamOrError(asString, name, paramDef, fileAlt, example, sink); } function resolveStandardParam( diff --git a/tests/unit/backends/shared-nativeToolPrompts.test.ts b/tests/unit/backends/shared-nativeToolPrompts.test.ts index fb0747b1..16e6daac 100644 --- a/tests/unit/backends/shared-nativeToolPrompts.test.ts +++ b/tests/unit/backends/shared-nativeToolPrompts.test.ts @@ -25,7 +25,11 @@ import { buildTaskPrompt, buildToolGuidance, } from '../../../src/backends/shared/nativeToolPrompts.js'; -import { createPRReviewDef, updatePRCommentDef } from '../../../src/gadgets/github/definitions.js'; +import { + createPRReviewDef, + replyToReviewCommentDef, + updatePRCommentDef, +} from '../../../src/gadgets/github/definitions.js'; import { readWorkItemDef } from '../../../src/gadgets/pm/definitions.js'; import { generateToolManifest } from '../../../src/gadgets/shared/manifestGenerator.js'; @@ -195,6 +199,13 @@ describe('buildToolGuidance', () => { expect(result).toContain('[--body-file ]'); expect(result).toContain('Read comment body from file (use - for stdin)'); }); + + it('renders ReplyToReviewComment body-file guidance from definition metadata', () => { + const result = buildToolGuidance([generateToolManifest(replyToReviewCommentDef)]); + + expect(result).toContain('[--body-file ]'); + expect(result).toContain('Read reply body from file (use - for stdin)'); + }); }); describe('formatParam — optional string param', () => { diff --git a/tests/unit/backends/toolManifests.test.ts b/tests/unit/backends/toolManifests.test.ts index 83079e23..473e4eb6 100644 --- a/tests/unit/backends/toolManifests.test.ts +++ b/tests/unit/backends/toolManifests.test.ts @@ -158,4 +158,17 @@ describe('getToolManifests', () => { 'body-file': { type: 'string' }, }); }); + + it('ReplyToReviewComment has body-file support', () => { + const manifests = getToolManifests(); + const replyToReviewComment = manifests.find((m) => m.name === 'ReplyToReviewComment'); + expect(replyToReviewComment).toBeDefined(); + expect(replyToReviewComment?.parameters).toMatchObject({ + body: { type: 'string', required: true }, + 'body-file': { + type: 'string', + description: 'Read reply body from file (use - for stdin)', + }, + }); + }); }); diff --git a/tests/unit/cli/cli-command-factory.test.ts b/tests/unit/cli/cli-command-factory.test.ts index a20aaa5f..d326f113 100644 --- a/tests/unit/cli/cli-command-factory.test.ts +++ b/tests/unit/cli/cli-command-factory.test.ts @@ -340,6 +340,97 @@ describe('cliCommandFactory — flag generation', () => { expect.objectContaining({ config: { key: 'value', num: 42 } }), ); }); + + it('passes canonical array-of-object JSON arrays unchanged', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const comments = [{ path: 'src/index.ts', line: 12, body: 'Please handle null here.' }]; + const def = makeToolDef({ + parameters: { + comments: { + type: 'array', + items: 'object', + describe: 'Inline comments', + optional: true, + cliAliases: ['comment'], + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--comments', JSON.stringify(comments)], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ comments })); + }); + + it('resolves singular array-of-object aliases with JSON arrays', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const comments = [{ path: 'src/index.ts', line: 12, body: 'Please handle null here.' }]; + const def = makeToolDef({ + parameters: { + comments: { + type: 'array', + items: 'object', + describe: 'Inline comments', + optional: true, + cliAliases: ['comment'], + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--comment', JSON.stringify(comments)], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ comments })); + }); + + it('normalizes singular array-of-object aliases with one JSON object', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const comment = { path: 'src/index.ts', line: 12, body: 'Please handle null here.' }; + const def = makeToolDef({ + parameters: { + comments: { + type: 'array', + items: 'object', + describe: 'Inline comments', + optional: true, + cliAliases: ['comment'], + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--comment', JSON.stringify(comment)], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ comments: [comment] })); + }); + + it('rejects primitive JSON for array-of-object params before calling the core function', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + comments: { + type: 'array', + items: 'object', + describe: 'Inline comments', + optional: true, + cliAliases: ['comment'], + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--comment', '"not an array"'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + + expect(coreFn).not.toHaveBeenCalled(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('json-parse'); + expect(output.error.flag).toBe('comments'); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/cli/file-input-flags.test.ts b/tests/unit/cli/file-input-flags.test.ts index ec111053..2acf1a95 100644 --- a/tests/unit/cli/file-input-flags.test.ts +++ b/tests/unit/cli/file-input-flags.test.ts @@ -50,6 +50,9 @@ vi.mock('../../../src/gadgets/github/core/postPRComment.js', () => ({ vi.mock('../../../src/gadgets/github/core/updatePRComment.js', () => ({ updatePRComment: vi.fn().mockResolvedValue({ id: 456 }), })); +vi.mock('../../../src/gadgets/github/core/replyToReviewComment.js', () => ({ + replyToReviewComment: vi.fn().mockResolvedValue('Reply posted successfully'), +})); import CreateWorkItem from '../../../src/cli/pm/create-work-item.js'; import PostComment from '../../../src/cli/pm/post-comment.js'; @@ -58,10 +61,12 @@ import UpdateWorkItem from '../../../src/cli/pm/update-work-item.js'; import CreatePR from '../../../src/cli/scm/create-pr.js'; import CreatePRReview from '../../../src/cli/scm/create-pr-review.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 { createPR } from '../../../src/gadgets/github/core/createPR.js'; import { createPRReview } from '../../../src/gadgets/github/core/createPRReview.js'; import { postPRComment } from '../../../src/gadgets/github/core/postPRComment.js'; +import { replyToReviewComment } from '../../../src/gadgets/github/core/replyToReviewComment.js'; import { updatePRComment } from '../../../src/gadgets/github/core/updatePRComment.js'; import { createWorkItem } from '../../../src/gadgets/pm/core/createWorkItem.js'; import { postComment } from '../../../src/gadgets/pm/core/postComment.js'; @@ -439,6 +444,58 @@ describe('CreatePRReview --body-file', () => { }), ); }); + + it('resolves the --comment alias with one JSON object', async () => { + const comment = { path: 'src/index.ts', line: 12, body: 'Please handle null here.' }; + const cmd = new CreatePRReview( + [ + '--prNumber', + '42', + '--event', + 'REQUEST_CHANGES', + '--body', + 'Needs a small change', + '--comment', + JSON.stringify(comment), + ], + mockConfig as never, + ); + await cmd.run(); + + expect(createPRReview).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'Needs a small change', + comments: [comment], + }), + ); + }); + + it('rejects primitive JSON passed through the --comment alias', async () => { + const cmd = new CreatePRReview( + [ + '--prNumber', + '42', + '--event', + 'REQUEST_CHANGES', + '--body', + 'Needs a small change', + '--comment', + '"not an array"', + ], + mockConfig as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + + expect(createPRReview).not.toHaveBeenCalled(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('json-parse'); + expect(output.error.flag).toBe('comments'); + }); }); describe('PostPRComment --body-file', () => { @@ -567,3 +624,73 @@ describe('UpdatePRComment --body-file', () => { expect(output.error.flag).toBe('body'); }); }); + +describe('ReplyToReviewComment --body-file', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + CASCADE_REPO_OWNER: 'owner', + CASCADE_REPO_NAME: 'repo', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('reads reply body from file', async () => { + const filePath = writeTempFile('reply.md', 'Review reply from file'); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '789', '--body-file', filePath], + mockConfig as never, + ); + await cmd.run(); + + expect(replyToReviewComment).toHaveBeenCalledWith( + 'owner', + 'repo', + 42, + 789, + 'Review reply from file', + ); + }); + + it('prefers --body-file over --body', async () => { + const filePath = writeTempFile('reply.md', 'from file'); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '789', '--body', 'from flag', '--body-file', filePath], + mockConfig as never, + ); + await cmd.run(); + + expect(replyToReviewComment).toHaveBeenCalledWith('owner', 'repo', 42, 789, 'from file'); + }); + + it('still works with inline --body flag', async () => { + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '789', '--body', 'inline body'], + mockConfig as never, + ); + await cmd.run(); + + expect(replyToReviewComment).toHaveBeenCalledWith('owner', 'repo', 42, 789, 'inline body'); + }); + + it('errors when neither --body nor --body-file is provided (spec 014 envelope)', async () => { + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '789'], + mockConfig as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('missing-required'); + expect(output.error.flag).toBe('body'); + }); +}); diff --git a/tests/unit/gadgets/github/definitions.test.ts b/tests/unit/gadgets/github/definitions.test.ts index aef7f3df..ccf8a250 100644 --- a/tests/unit/gadgets/github/definitions.test.ts +++ b/tests/unit/gadgets/github/definitions.test.ts @@ -254,6 +254,15 @@ describe('GitHub SCM gadget definitions', () => { expect(replyToReviewCommentDef.parameters.commentId?.required).toBe(true); expect(replyToReviewCommentDef.parameters.body?.required).toBe(true); }); + + it('has body file input alternative', () => { + const bodyAlt = replyToReviewCommentDef.cli?.fileInputAlternatives?.find( + (a) => a.paramName === 'body', + ); + expect(bodyAlt).toBeDefined(); + expect(bodyAlt?.fileFlag).toBe('body-file'); + expect(bodyAlt?.description).toBeTruthy(); + }); }); // ─── UpdatePRComment specific ───────────────────────────────────────────── diff --git a/tests/unit/gadgets/shared/cli/params.test.ts b/tests/unit/gadgets/shared/cli/params.test.ts index 288e76a0..2bf71ea5 100644 --- a/tests/unit/gadgets/shared/cli/params.test.ts +++ b/tests/unit/gadgets/shared/cli/params.test.ts @@ -89,6 +89,78 @@ describe('CLI parameter resolution', () => { }); }); + it('wraps a top-level object for array-of-object file inputs', () => { + const filePath = writeTempFile('comments.json', '{"path":"from-file.ts"}'); + const params = resolveDirectParams( + def, + { + body: 'hello', + 'comments-file': filePath, + }, + new Map([['comments', fileAlt]]), + new Map([['owner', autoResolved]]), + makeSink(), + ); + + expect(params).toEqual({ + body: 'hello', + comments: [{ path: 'from-file.ts' }], + }); + }); + + it('wraps a top-level object for inline array-of-object values', () => { + const params = resolveDirectParams( + def, + { + body: 'hello', + comments: '{"path":"inline.ts"}', + }, + new Map([['comments', fileAlt]]), + new Map([['owner', autoResolved]]), + makeSink(), + ); + + expect(params).toEqual({ + body: 'hello', + comments: [{ path: 'inline.ts' }], + }); + }); + + it('keeps arrays unchanged without validating individual entries', () => { + const params = resolveDirectParams( + def, + { + body: 'hello', + comments: '["AddChecklist-compatible string entry",{"path":"inline.ts"}]', + }, + new Map([['comments', fileAlt]]), + new Map([['owner', autoResolved]]), + makeSink(), + ); + + expect(params).toEqual({ + body: 'hello', + comments: ['AddChecklist-compatible string entry', { path: 'inline.ts' }], + }); + }); + + it('emits json-parse when array-of-object JSON has an impossible top-level shape', () => { + const sink = makeSink(); + expect(() => + resolveDirectParams( + def, + { + body: 'hello', + comments: '"not an array"', + }, + new Map([['comments', fileAlt]]), + new Map([['owner', autoResolved]]), + sink, + ), + ).toThrow('exit'); + expect(sink.exit).toHaveBeenCalledWith(1); + }); + it('emits missing-required when neither inline nor file value is present', () => { expect(() => resolveDirectParams(