@haystackeditor/cli 0.15.19 → 0.15.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +100 -1
  2. package/dist/assets/skills/map-your-system.md +9 -4
  3. package/dist/commands/ask.js +2 -2
  4. package/dist/commands/config.d.ts +9 -0
  5. package/dist/commands/config.js +46 -14
  6. package/dist/commands/dismiss.d.ts +15 -3
  7. package/dist/commands/dismiss.js +66 -116
  8. package/dist/commands/mcp.js +96 -1
  9. package/dist/commands/policy.js +31 -12
  10. package/dist/commands/pr-status.d.ts +125 -2
  11. package/dist/commands/pr-status.js +88 -73
  12. package/dist/commands/pr.d.ts +1 -7
  13. package/dist/commands/pr.js +10 -21
  14. package/dist/commands/request-review.d.ts +12 -1
  15. package/dist/commands/request-review.js +42 -65
  16. package/dist/commands/review.d.ts +1 -0
  17. package/dist/commands/review.js +58 -60
  18. package/dist/commands/setup.js +4 -1
  19. package/dist/commands/submit.d.ts +3 -0
  20. package/dist/commands/submit.js +178 -38
  21. package/dist/commands/system-map.d.ts +42 -0
  22. package/dist/commands/system-map.js +228 -0
  23. package/dist/commands/tokens.js +3 -2
  24. package/dist/commands/traces.js +2 -2
  25. package/dist/commands/triage.d.ts +1 -6
  26. package/dist/commands/triage.js +51 -55
  27. package/dist/commands/verify-core.d.ts +424 -0
  28. package/dist/commands/verify-core.js +753 -0
  29. package/dist/commands/verify-mcp.d.ts +8 -0
  30. package/dist/commands/verify-mcp.js +458 -0
  31. package/dist/commands/verify-ops.d.ts +158 -0
  32. package/dist/commands/verify-ops.js +1148 -0
  33. package/dist/commands/verify-reseal.d.ts +9 -0
  34. package/dist/commands/verify-reseal.js +148 -0
  35. package/dist/commands/verify-sandboxes.d.ts +95 -0
  36. package/dist/commands/verify-sandboxes.js +352 -0
  37. package/dist/commands/verify.d.ts +11 -0
  38. package/dist/commands/verify.js +97 -0
  39. package/dist/index.js +343 -20
  40. package/dist/schema.d.ts +4 -0
  41. package/dist/schema.js +4 -0
  42. package/dist/states.d.ts +29 -0
  43. package/dist/states.js +46 -0
  44. package/dist/triage/runner.d.ts +7 -0
  45. package/dist/triage/runner.js +2 -2
  46. package/dist/types.d.ts +54 -0
  47. package/dist/types.js +15 -0
  48. package/dist/utils/action-output.d.ts +24 -0
  49. package/dist/utils/action-output.js +26 -0
  50. package/dist/utils/analysis-api.d.ts +11 -0
  51. package/dist/utils/analysis-api.js +29 -3
  52. package/dist/utils/auth.js +7 -0
  53. package/dist/utils/git.d.ts +4 -1
  54. package/dist/utils/git.js +17 -7
  55. package/dist/utils/haystack-api.d.ts +15 -0
  56. package/dist/utils/haystack-api.js +58 -7
  57. package/dist/utils/pr-ref.d.ts +27 -0
  58. package/dist/utils/pr-ref.js +56 -0
  59. package/package.json +2 -2
  60. package/schemas/action.v1.json +22 -0
  61. package/schemas/error.v1.json +14 -0
  62. package/schemas/pr-status.v1.json +61 -0
  63. package/schemas/pr.v1.json +80 -17
  64. package/schemas/setup.v1.json +66 -12
  65. package/schemas/submit.v1.json +88 -0
  66. package/schemas/triage.v1.json +71 -18
package/README.md CHANGED
@@ -21,6 +21,74 @@ The `setup` command walks you through an interactive wizard:
21
21
 
22
22
  ---
23
23
 
24
+ ## For AI agents: the machine-readable contract
25
+
26
+ Most consumers of this CLI are coding agents. These invariants hold everywhere:
27
+
28
+ - **`--json` means pure stdout.** When `--json` is passed, stdout carries exactly
29
+ one JSON document (NDJSON stream for `setup --json`); all progress, spinners,
30
+ and prose go to stderr. On failure, stdout still gets a versioned error
31
+ envelope (`haystack schema error`: `{"schema_version": "1.0.0", "status":
32
+ "error", "error": "..."}`) and the process exits 1.
33
+ - **Every JSON payload is versioned and schema'd.** Payloads carry
34
+ `schema_version`; print the contract with `haystack schema <name>`
35
+ (`haystack schema` lists all: `triage`, `pr`, `pr-status`, `inbox`, `ask`,
36
+ `traces`, `submit`, `action`, `error`, `setup`). Schemas are JSON-Schema
37
+ 2020-12 and CI-guarded against drift.
38
+ - **One state vocabulary.** All state tokens are snake_case across every
39
+ command: verdicts are `good_to_merge` / `needs_review` / `needs_input`;
40
+ feed buckets are `analyzing`, `good_to_merge`, `issues`, `needs_assignment`,
41
+ `needs_shepherding`, etc. No command emits kebab-case states.
42
+ - **PR refs are uniform.** Every command taking a PR accepts `123`, `#123`,
43
+ `owner/repo#123`, or a GitHub PR URL. Bare numbers infer the repo from the
44
+ `origin` remote.
45
+ - **Exit codes**: 0 = the requested action happened (a created PR with findings
46
+ is still 0 — read the verdict from the JSON); 1 = it did not. Commands never
47
+ print an error and exit 0.
48
+ - **Waiting is bounded and skippable.** `submit` waits up to 10 min for
49
+ analysis, `triage` polls up to 5 min, `review` up to 25 min; every waiting
50
+ command takes `--no-wait`. Non-terminal outcomes include a `next` field with
51
+ the follow-up command (usually `haystack triage <ref> --json`).
52
+ - **"review" disambiguation**: `haystack review` re-runs the *machine*
53
+ analysis. For *human* review use `haystack submit --review` or
54
+ `haystack request-review`.
55
+
56
+ **The agent workflow for submitting:**
57
+
58
+ ```bash
59
+ haystack submit --json # emits the submit payload incl. ref + analysis verdict
60
+ haystack triage <ref> --json # poll findings later / after --no-wait
61
+ ```
62
+
63
+ **`haystack mcp`** runs a stdio MCP server exposing the same payloads as tools:
64
+ `inbox_list`, `pr_get`, `pr_status`, `triage_get`, `ask_haystack`,
65
+ `traces_list`, `traces_get`, `dismiss`, `mark_reviewed`, `undismiss`,
66
+ `request_review`, `trigger_review`, and `schema`.
67
+
68
+ **`haystack verify mcp`** runs the cloud-verifier MCP server. Retained
69
+ application state is available through `verify_reopen`, `verify_refork`, and
70
+ `verify_cleanup`. Agents select state by an exact Archil fork ID or a stable
71
+ run/test/side coordinate; refork returns a durable child ID that can be
72
+ reopened or nested again without receiving provider credentials. The matching
73
+ CLI flow is:
74
+
75
+ ```bash
76
+ haystack verify reopen latest 3
77
+ # inspect or mutate the returned sandbox
78
+ haystack verify refork latest 3 attempt:fix-2
79
+ haystack verify reopen archil:<disk-id>:branch:<returned-child>
80
+ ```
81
+
82
+ **`haystack setup --json`** speaks NDJSON: events (`question`, `permission`,
83
+ `progress`, `result`) on stdout, replies on stdin keyed by
84
+ `requestID`/`permissionID`. `haystack schema setup` documents the full
85
+ protocol, including the reply shapes. Pre-supply answers with `--repo`,
86
+ `--yes`, `--no-auto-merge`, or `--answers <file>` to skip questions. Note:
87
+ `--yes` enables auto-merge (the wizard default) — pass `--no-auto-merge` to
88
+ opt out.
89
+
90
+ ---
91
+
24
92
  ## CLI Commands
25
93
 
26
94
  ### `haystack setup`
@@ -69,12 +137,18 @@ Create a PR from current changes. Runs pre-PR triage (code review, rules validat
69
137
 
70
138
  ```bash
71
139
  haystack submit # Triage -> create PR -> wait for analysis
140
+ haystack submit --json # Agent mode: one JSON doc on stdout, progress on stderr
72
141
  haystack submit --title "Fix auth" # Custom PR title
73
142
  haystack submit --draft # Create as draft PR
74
143
  haystack submit --force # Skip triage checks
75
144
  haystack submit --no-wait # Don't wait for analysis results
76
145
  ```
77
146
 
147
+ The `--json` payload (`haystack schema submit`) reports the PR ref, the
148
+ resolved title/body and where each came from, auto-merge/auto-fix state with
149
+ its source (flag vs `.haystack.json`), and the analysis outcome in the shared
150
+ verdict vocabulary.
151
+
78
152
  **Review routing**: By default, PRs go to the auto-merge queue -- if analysis passes, the PR is merged automatically. Use `--review` to route it for human review instead:
79
153
 
80
154
  ```bash
@@ -145,6 +219,26 @@ haystack traces get owner/repo#42 <checkpoint-id> --cursor 20 --limit 20 --json
145
219
 
146
220
  These commands expose customer coding-session data, not Haystack's internal analysis-agent execution.
147
221
 
222
+ ### `haystack review`
223
+
224
+ Trigger a fresh *machine* analysis of a PR's current head (Haystack analyzes once at PR open; later pushes only get a resolution check). For requesting *human* review, use `haystack request-review`.
225
+
226
+ ```bash
227
+ haystack review 42 # Re-analyze and wait for the result
228
+ haystack review 42 --no-wait # Trigger and exit
229
+ haystack review 42 --json # Machine-readable outcome (schema: action)
230
+ ```
231
+
232
+ ### `haystack request-review`
233
+
234
+ Tag a PR as needing human review (adds `haystack:needs-review`), optionally requesting a specific GitHub user:
235
+
236
+ ```bash
237
+ haystack request-review 42 # Into the needs-assignment queue
238
+ haystack request-review 42 octocat # Also request review from octocat
239
+ haystack request-review 42 --json
240
+ ```
241
+
148
242
  ### `haystack dismiss`
149
243
 
150
244
  Dismiss analysis findings for a PR, moving it from "Issues Found" to "Good to Merge" in the feed. The override is tied to the PR's current HEAD commit.
@@ -152,6 +246,7 @@ Dismiss analysis findings for a PR, moving it from "Issues Found" to "Good to Me
152
246
  ```bash
153
247
  haystack dismiss 42 # Dismiss findings for PR #42
154
248
  haystack dismiss acme/widgets#99 # Dismiss for specific repo
249
+ haystack dismiss 42 --json # Machine-readable (schema: action)
155
250
  ```
156
251
 
157
252
  ### `haystack undismiss`
@@ -205,10 +300,14 @@ haystack config wait-for-reviewers add cursor # Wait for Cursor BugBot
205
300
  haystack config wait-for-reviewers add cursor coderabbit # Add multiple
206
301
  haystack config wait-for-reviewers remove cursor # Stop waiting
207
302
  haystack config wait-for-reviewers clear # Wait for none
208
- # Also accepts raw GitHub bot usernames:
303
+ # Also accepts raw GitHub bot usernames (must end in [bot]):
209
304
  haystack config wait-for-reviewers add cursor-bugbot[bot]
210
305
  ```
211
306
 
307
+ Reviewer names are validated: anything that is neither a known friendly name
308
+ nor a `...[bot]` username is rejected (a stored typo would make the merge
309
+ queue wait forever for a bot that can't post).
310
+
212
311
  ### `haystack skills`
213
312
 
214
313
  Manage AI skills for your coding CLI:
@@ -108,11 +108,16 @@ constraints:
108
108
  Every section is optional — include only what you verified. All commands run
109
109
  from the repo root unless a `cwd`/`root` says otherwise.
110
110
 
111
- ### 5. Self-check before opening the PR
111
+ ### 5. Validate before opening the PR
112
+
113
+ Run the validator — it applies the same rules Haystack QA applies:
114
+
115
+ ```bash
116
+ npx @haystackeditor/cli system-map validate
117
+ ```
118
+
119
+ Fix every reported error, then confirm the things the validator cannot check:
112
120
 
113
- - [ ] `version: 1` is present and the file is valid YAML
114
- - [ ] no test-selection keys (`scenarios`, `tests`, `skip`, `focus`, ...)
115
- - [ ] no secret values anywhere (env var names only)
116
121
  - [ ] every `run`/`boot`/`login`/`seed` command was executed and worked
117
122
  - [ ] every service has a `ready` signal you observed
118
123
  - [ ] personas are seeded fake users via dev/test-only paths
@@ -1,9 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadToken } from '../utils/auth.js';
3
3
  import { postAskHaystack } from '../utils/haystack-api.js';
4
- import { parsePrRef } from './pr.js';
4
+ import { parsePrRef } from '../utils/pr-ref.js';
5
5
  export async function fetchAskPayload(ref, question, options) {
6
- const pr = parsePrRef(ref);
6
+ const pr = parsePrRef(ref, 'ask');
7
7
  const token = await loadToken({ owner: pr.owner, repo: pr.repo });
8
8
  if (!token)
9
9
  throw new Error('Not authenticated. Run `haystack login` first.');
@@ -33,5 +33,14 @@ export declare function getAutoFixStatus(): Promise<void>;
33
33
  export declare function enableAutoFix(): Promise<void>;
34
34
  export declare function disableAutoFix(): Promise<void>;
35
35
  export declare function handleAutoFix(action?: string): Promise<void>;
36
+ /**
37
+ * Resolve a source name (e.g. "cursor") to its GitHub bot username. Raw bot
38
+ * usernames (anything ending in `[bot]`) pass through unchanged.
39
+ *
40
+ * Unknown names are REJECTED, not stored: this value must exactly match a
41
+ * GitHub bot login or the merge queue waits forever for a reviewer that can
42
+ * never post. (A typo used to be saved verbatim and reported as success.)
43
+ */
44
+ export declare function resolveToBot(input: string): string;
36
45
  export declare function handleWaitForReviewers(action?: string, reviewers?: string[]): Promise<void>;
37
46
  export {};
@@ -411,14 +411,32 @@ function printAvailableReviewers(currentBots) {
411
411
  }
412
412
  console.log();
413
413
  }
414
- /** Resolve a source name (e.g. "cursor") to its GitHub bot username. Also accepts raw bot usernames. */
415
- function resolveToBot(input) {
414
+ /**
415
+ * Resolve a source name (e.g. "cursor") to its GitHub bot username. Raw bot
416
+ * usernames (anything ending in `[bot]`) pass through unchanged.
417
+ *
418
+ * Unknown names are REJECTED, not stored: this value must exactly match a
419
+ * GitHub bot login or the merge queue waits forever for a reviewer that can
420
+ * never post. (A typo used to be saved verbatim and reported as success.)
421
+ */
422
+ export function resolveToBot(input) {
423
+ const lower = input.toLowerCase();
424
+ if (AI_REVIEWER_SOURCES.includes(lower)) {
425
+ return AI_REVIEWER_BOT_USERNAMES[lower];
426
+ }
427
+ if (input.endsWith('[bot]')) {
428
+ return input;
429
+ }
430
+ throw new Error(`Unknown reviewer: "${input}".\n\n` +
431
+ `Use a friendly name (${AI_REVIEWER_SOURCES.join(', ')})\n` +
432
+ `or an exact GitHub bot username ending in [bot] (e.g. cursor-bugbot[bot]).`);
433
+ }
434
+ /** Like resolveToBot, but unknown names pass through verbatim (for `remove`). */
435
+ function resolveToBotLenient(input) {
416
436
  const lower = input.toLowerCase();
417
- // Check if it's a known friendly source name
418
437
  if (AI_REVIEWER_SOURCES.includes(lower)) {
419
438
  return AI_REVIEWER_BOT_USERNAMES[lower];
420
439
  }
421
- // Already a bot username (e.g. "cursor-bugbot[bot]")
422
440
  return input;
423
441
  }
424
442
  function displayName(botUsername) {
@@ -463,7 +481,15 @@ export async function handleWaitForReviewers(action, reviewers) {
463
481
  process.exit(1);
464
482
  return; // unreachable — satisfies TS narrowing
465
483
  }
466
- const resolved = reviewers.map(resolveToBot);
484
+ let resolved;
485
+ try {
486
+ resolved = reviewers.map(resolveToBot);
487
+ }
488
+ catch (err) {
489
+ console.error(chalk.red(`\n${err.message}\n`));
490
+ process.exit(1);
491
+ return; // unreachable — satisfies TS narrowing
492
+ }
467
493
  const { config, path, bots: current } = getMergeQueueConfig();
468
494
  const merged = Array.from(new Set([...current, ...resolved]));
469
495
  saveMergeQueueBots(config, path, merged);
@@ -483,7 +509,10 @@ export async function handleWaitForReviewers(action, reviewers) {
483
509
  process.exit(1);
484
510
  return; // unreachable — satisfies TS narrowing
485
511
  }
486
- const resolved = reviewers.map(resolveToBot);
512
+ // Lenient on remove: stored entries may predate strict validation
513
+ // (arbitrary strings used to be saved verbatim), and removing them must
514
+ // stay possible.
515
+ const resolved = reviewers.map(resolveToBotLenient);
487
516
  const { config, path, bots: current } = getMergeQueueConfig();
488
517
  const removeSet = new Set(resolved);
489
518
  const remaining = current.filter(b => !removeSet.has(b));
@@ -506,12 +535,15 @@ export async function handleWaitForReviewers(action, reviewers) {
506
535
  console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
507
536
  return;
508
537
  }
509
- // Unknown action — maybe the user passed reviewer names directly (implicit 'add')
510
- const allNames = [action, ...(reviewers ?? [])].filter(Boolean);
511
- const resolved = allNames.map(resolveToBot);
512
- const { config, path, bots: current } = getMergeQueueConfig();
513
- const merged = Array.from(new Set([...current, ...resolved]));
514
- saveMergeQueueBots(config, path, merged);
515
- console.log(chalk.green(`\nAdded: ${resolved.map(displayName).join(', ')}`));
516
- console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
538
+ // Unknown action — reject it. This used to fall through to an implicit
539
+ // "add", so a typo'd action (`wait-for-reviewers ad cursor`) silently
540
+ // stored a bogus bot named "ad" and reported success.
541
+ console.error(chalk.red(`\nUnknown action: "${action}".`));
542
+ console.log('\nActions:');
543
+ console.log(' list Show configured reviewer bots (default)');
544
+ console.log(' add <names> Add one or more reviewer bots');
545
+ console.log(' remove <names> Remove one or more reviewer bots');
546
+ console.log(' clear Remove all reviewer bots');
547
+ console.log(chalk.dim('\nExample: haystack config wait-for-reviewers add cursor\n'));
548
+ process.exit(1);
517
549
  }
@@ -12,6 +12,18 @@
12
12
  * owner/repo#123 # Fully qualified
13
13
  * https://github.com/owner/repo/pull/123 # GitHub URL
14
14
  */
15
- export declare function dismissCommand(identifier: string): Promise<void>;
16
- export declare function markReviewedCommand(identifier: string): Promise<void>;
17
- export declare function undismissCommand(identifier: string): Promise<void>;
15
+ import { type ActionPayload } from '../utils/action-output.js';
16
+ type OverrideType = 'findings-dismissed' | 'review-not-needed';
17
+ /** Post a user override and return the action payload. Throws on failure. */
18
+ export declare function performOverride(identifier: string, type: OverrideType, commandName: string): Promise<ActionPayload & {
19
+ head_sha: string;
20
+ }>;
21
+ /** Clear all overrides and return the action payload. Throws on failure. */
22
+ export declare function performUndismiss(identifier: string): Promise<ActionPayload>;
23
+ export interface OverrideCommandOptions {
24
+ json?: boolean;
25
+ }
26
+ export declare function dismissCommand(identifier: string, options?: OverrideCommandOptions): Promise<void>;
27
+ export declare function markReviewedCommand(identifier: string, options?: OverrideCommandOptions): Promise<void>;
28
+ export declare function undismissCommand(identifier: string, options?: OverrideCommandOptions): Promise<void>;
29
+ export {};
@@ -13,38 +13,10 @@
13
13
  * https://github.com/owner/repo/pull/123 # GitHub URL
14
14
  */
15
15
  import chalk from 'chalk';
16
+ import { emitActionPayload, failAction } from '../utils/action-output.js';
16
17
  import { resolveAuthContext } from '../utils/auth.js';
17
- import { parseRemoteUrl } from '../utils/git.js';
18
- // ============================================================================
19
- // PR identifier parsing (shared with triage — duplicated to avoid circular deps)
20
- // ============================================================================
21
- function parsePRIdentifier(identifier, commandName) {
22
- const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
23
- if (urlMatch) {
24
- return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
25
- }
26
- const qualifiedMatch = identifier.match(/^([^/]+)\/([^#]+)#(\d+)$/);
27
- if (qualifiedMatch) {
28
- return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
29
- }
30
- const numberMatch = identifier.match(/^#?(\d+)$/);
31
- if (numberMatch) {
32
- const prNumber = parseInt(numberMatch[1], 10);
33
- try {
34
- const remote = parseRemoteUrl('origin');
35
- return { owner: remote.owner, repo: remote.repo, prNumber };
36
- }
37
- catch {
38
- throw new Error(`Cannot determine repository for PR #${prNumber}.\n` +
39
- `Use the full format: haystack ${commandName} owner/repo#${prNumber}`);
40
- }
41
- }
42
- throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
43
- `Accepted formats:\n` +
44
- ` haystack ${commandName} 123\n` +
45
- ` haystack ${commandName} owner/repo#123\n` +
46
- ` haystack ${commandName} https://github.com/owner/repo/pull/123`);
47
- }
18
+ import { classifyHttpError } from '../utils/haystack-api.js';
19
+ import { parsePrRef } from '../utils/pr-ref.js';
48
20
  // ============================================================================
49
21
  // GitHub API helpers
50
22
  // ============================================================================
@@ -60,7 +32,7 @@ async function getPRHeadSha(owner, repo, prNumber, token) {
60
32
  if (response.status === 404) {
61
33
  throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
62
34
  }
63
- throw new Error(`Failed to fetch PR: HTTP ${response.status}`);
35
+ throw await classifyHttpError(response, `Fetching PR #${prNumber}`);
64
36
  }
65
37
  const data = await response.json();
66
38
  return data.head.sha;
@@ -80,14 +52,7 @@ async function postUserOverride(repoFullName, prNumber, commitSha, type, token)
80
52
  body: JSON.stringify({ repoFullName, prNumber, commitSha, type }),
81
53
  });
82
54
  if (!response.ok) {
83
- const body = await response.text().catch(() => '');
84
- if (response.status === 401) {
85
- throw new Error('Authentication failed. Run `haystack login` to re-authenticate.');
86
- }
87
- if (response.status === 403) {
88
- throw new Error('Access denied — you may not have permission to this repository.');
89
- }
90
- throw new Error(`API error (${response.status}): ${body}`);
55
+ throw await classifyHttpError(response, 'User override');
91
56
  }
92
57
  }
93
58
  async function deleteUserOverrides(repoFullName, prNumber, token) {
@@ -101,108 +66,93 @@ async function deleteUserOverrides(repoFullName, prNumber, token) {
101
66
  body: JSON.stringify({ repoFullName, prNumber }),
102
67
  });
103
68
  if (!response.ok) {
104
- const body = await response.text().catch(() => '');
105
- if (response.status === 401) {
106
- throw new Error('Authentication failed. Run `haystack login` to re-authenticate.');
107
- }
108
- if (response.status === 403) {
109
- throw new Error('Access denied — you may not have permission to this repository.');
110
- }
111
- throw new Error(`API error (${response.status}): ${body}`);
69
+ throw await classifyHttpError(response, 'Clearing user overrides');
112
70
  }
113
71
  }
114
72
  // ============================================================================
115
- // Shared runner
73
+ // Pure payload functions (shared by the CLI commands and the MCP server —
74
+ // they throw instead of exiting, and never write to stdout)
116
75
  // ============================================================================
117
- async function runOverride(identifier, type, commandName) {
118
- // Parse PR identifier
119
- let pr;
120
- try {
121
- pr = parsePRIdentifier(identifier, commandName);
122
- }
123
- catch (err) {
124
- console.error(chalk.red(`\n${err.message}\n`));
125
- process.exit(1);
126
- }
127
- // Require auth
128
- let authContext;
129
- try {
130
- authContext = await resolveAuthContext({ owner: pr.owner, repo: pr.repo });
131
- }
132
- catch (err) {
133
- console.error(chalk.red(`\n${err.message}\n`));
134
- process.exit(1);
135
- }
136
- const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
76
+ /** Post a user override and return the action payload. Throws on failure. */
77
+ export async function performOverride(identifier, type, commandName) {
78
+ const pr = parsePrRef(identifier, commandName);
79
+ const authContext = await resolveAuthContext({ owner: pr.owner, repo: pr.repo });
137
80
  const repoFullName = `${pr.owner}/${pr.repo}`;
81
+ const headSha = await getPRHeadSha(pr.owner, pr.repo, pr.prNumber, authContext.token);
82
+ await postUserOverride(repoFullName, pr.prNumber, headSha, type, authContext.token);
83
+ return {
84
+ action: type === 'findings-dismissed' ? 'dismiss' : 'mark_reviewed',
85
+ status: 'ok',
86
+ ref: `${repoFullName}#${pr.prNumber}`,
87
+ head_sha: headSha,
88
+ detail: type === 'findings-dismissed'
89
+ ? 'Findings dismissed; the PR moves to "Good to Merge". A new commit with net-new issues resurfaces it.'
90
+ : 'Human review marked as not needed; the PR moves to "Good to Merge". A new commit invalidates the override.',
91
+ };
92
+ }
93
+ /** Clear all overrides and return the action payload. Throws on failure. */
94
+ export async function performUndismiss(identifier) {
95
+ const pr = parsePrRef(identifier, 'undismiss');
96
+ const authContext = await resolveAuthContext({ owner: pr.owner, repo: pr.repo });
97
+ const repoFullName = `${pr.owner}/${pr.repo}`;
98
+ await deleteUserOverrides(repoFullName, pr.prNumber, authContext.token);
99
+ return {
100
+ action: 'undismiss',
101
+ status: 'ok',
102
+ ref: `${repoFullName}#${pr.prNumber}`,
103
+ detail: 'All overrides cleared; the PR returns to its original feed bucket.',
104
+ };
105
+ }
106
+ async function runOverride(identifier, type, commandName, options) {
107
+ const action = type === 'findings-dismissed' ? 'dismiss' : 'mark_reviewed';
138
108
  const actionLabel = type === 'findings-dismissed'
139
109
  ? 'Dismissing findings'
140
110
  : 'Marking review as not needed';
141
- console.log(chalk.dim(`\n${actionLabel} for ${prLabel}...`));
142
- // Get PR HEAD SHA
143
- let headSha;
111
+ if (!options.json)
112
+ console.log(chalk.dim(`\n${actionLabel} for ${identifier}...`));
113
+ let payload;
144
114
  try {
145
- headSha = await getPRHeadSha(pr.owner, pr.repo, pr.prNumber, authContext.token);
115
+ payload = await performOverride(identifier, type, commandName);
146
116
  }
147
117
  catch (err) {
148
- console.error(chalk.red(`\n${err.message}\n`));
149
- process.exit(1);
118
+ failAction(options.json, action, null, err.message);
119
+ return; // unreachable — satisfies TS narrowing
150
120
  }
151
- // Post the override
152
- try {
153
- await postUserOverride(repoFullName, pr.prNumber, headSha, type, authContext.token);
154
- }
155
- catch (err) {
156
- console.error(chalk.red(`\n${err.message}\n`));
157
- process.exit(1);
121
+ if (options.json) {
122
+ emitActionPayload(payload);
123
+ return;
158
124
  }
159
- // Success
160
125
  if (type === 'findings-dismissed') {
161
- console.log(chalk.green(`\n Findings dismissed for ${prLabel}`));
126
+ console.log(chalk.green(`\n Findings dismissed for ${payload.ref}`));
162
127
  }
163
128
  else {
164
- console.log(chalk.green(`\n Review marked as not needed for ${prLabel}`));
129
+ console.log(chalk.green(`\n Review marked as not needed for ${payload.ref}`));
165
130
  }
166
- console.log(chalk.dim(` Commit: ${headSha.slice(0, 7)}`));
131
+ console.log(chalk.dim(` Commit: ${payload.head_sha.slice(0, 7)}`));
167
132
  console.log(chalk.dim(' The PR will move to "Good to Merge" in the feed.'));
168
133
  console.log(chalk.dim(' If a new commit introduces net-new issues, the PR will resurface.\n'));
169
134
  }
170
- // ============================================================================
171
- // Exported commands
172
- // ============================================================================
173
- export async function dismissCommand(identifier) {
174
- await runOverride(identifier, 'findings-dismissed', 'dismiss');
135
+ export async function dismissCommand(identifier, options = {}) {
136
+ await runOverride(identifier, 'findings-dismissed', 'dismiss', options);
175
137
  }
176
- export async function markReviewedCommand(identifier) {
177
- await runOverride(identifier, 'review-not-needed', 'mark-reviewed');
138
+ export async function markReviewedCommand(identifier, options = {}) {
139
+ await runOverride(identifier, 'review-not-needed', 'mark-reviewed', options);
178
140
  }
179
- export async function undismissCommand(identifier) {
180
- let pr;
181
- try {
182
- pr = parsePRIdentifier(identifier, 'undismiss');
183
- }
184
- catch (err) {
185
- console.error(chalk.red(`\n${err.message}\n`));
186
- process.exit(1);
187
- }
188
- let authContext;
141
+ export async function undismissCommand(identifier, options = {}) {
142
+ if (!options.json)
143
+ console.log(chalk.dim(`\nClearing overrides for ${identifier}...`));
144
+ let payload;
189
145
  try {
190
- authContext = await resolveAuthContext({ owner: pr.owner, repo: pr.repo });
146
+ payload = await performUndismiss(identifier);
191
147
  }
192
148
  catch (err) {
193
- console.error(chalk.red(`\n${err.message}\n`));
194
- process.exit(1);
149
+ failAction(options.json, 'undismiss', null, err.message);
150
+ return; // unreachable — satisfies TS narrowing
195
151
  }
196
- const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
197
- const repoFullName = `${pr.owner}/${pr.repo}`;
198
- console.log(chalk.dim(`\nClearing overrides for ${prLabel}...`));
199
- try {
200
- await deleteUserOverrides(repoFullName, pr.prNumber, authContext.token);
201
- }
202
- catch (err) {
203
- console.error(chalk.red(`\n${err.message}\n`));
204
- process.exit(1);
152
+ if (options.json) {
153
+ emitActionPayload(payload);
154
+ return;
205
155
  }
206
- console.log(chalk.green(`\n Overrides cleared for ${prLabel}`));
156
+ console.log(chalk.green(`\n Overrides cleared for ${payload.ref}`));
207
157
  console.log(chalk.dim(' The PR will return to its original feed bucket.\n'));
208
158
  }