@haystackeditor/cli 0.15.18 → 0.15.20

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 (53) hide show
  1. package/README.md +86 -1
  2. package/dist/assets/skills/map-your-system.md +143 -0
  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 +25 -0
  17. package/dist/commands/review.js +191 -0
  18. package/dist/commands/setup.js +4 -1
  19. package/dist/commands/skills.js +4 -0
  20. package/dist/commands/submit.d.ts +3 -0
  21. package/dist/commands/submit.js +178 -38
  22. package/dist/commands/system-map.d.ts +42 -0
  23. package/dist/commands/system-map.js +228 -0
  24. package/dist/commands/tokens.js +3 -2
  25. package/dist/commands/traces.js +2 -2
  26. package/dist/commands/triage.d.ts +1 -6
  27. package/dist/commands/triage.js +51 -55
  28. package/dist/index.js +116 -19
  29. package/dist/schema.d.ts +4 -0
  30. package/dist/schema.js +4 -0
  31. package/dist/states.d.ts +29 -0
  32. package/dist/states.js +46 -0
  33. package/dist/triage/runner.d.ts +7 -0
  34. package/dist/triage/runner.js +2 -2
  35. package/dist/utils/action-output.d.ts +24 -0
  36. package/dist/utils/action-output.js +26 -0
  37. package/dist/utils/analysis-api.d.ts +44 -0
  38. package/dist/utils/analysis-api.js +137 -6
  39. package/dist/utils/auth.js +7 -0
  40. package/dist/utils/git.d.ts +4 -1
  41. package/dist/utils/git.js +17 -7
  42. package/dist/utils/haystack-api.d.ts +15 -0
  43. package/dist/utils/haystack-api.js +58 -7
  44. package/dist/utils/pr-ref.d.ts +27 -0
  45. package/dist/utils/pr-ref.js +56 -0
  46. package/package.json +1 -1
  47. package/schemas/action.v1.json +22 -0
  48. package/schemas/error.v1.json +14 -0
  49. package/schemas/pr-status.v1.json +61 -0
  50. package/schemas/pr.v1.json +80 -17
  51. package/schemas/setup.v1.json +66 -12
  52. package/schemas/submit.v1.json +88 -0
  53. package/schemas/triage.v1.json +71 -18
package/README.md CHANGED
@@ -21,6 +21,60 @@ 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 setup --json`** speaks NDJSON: events (`question`, `permission`,
69
+ `progress`, `result`) on stdout, replies on stdin keyed by
70
+ `requestID`/`permissionID`. `haystack schema setup` documents the full
71
+ protocol, including the reply shapes. Pre-supply answers with `--repo`,
72
+ `--yes`, `--no-auto-merge`, or `--answers <file>` to skip questions. Note:
73
+ `--yes` enables auto-merge (the wizard default) — pass `--no-auto-merge` to
74
+ opt out.
75
+
76
+ ---
77
+
24
78
  ## CLI Commands
25
79
 
26
80
  ### `haystack setup`
@@ -69,12 +123,18 @@ Create a PR from current changes. Runs pre-PR triage (code review, rules validat
69
123
 
70
124
  ```bash
71
125
  haystack submit # Triage -> create PR -> wait for analysis
126
+ haystack submit --json # Agent mode: one JSON doc on stdout, progress on stderr
72
127
  haystack submit --title "Fix auth" # Custom PR title
73
128
  haystack submit --draft # Create as draft PR
74
129
  haystack submit --force # Skip triage checks
75
130
  haystack submit --no-wait # Don't wait for analysis results
76
131
  ```
77
132
 
133
+ The `--json` payload (`haystack schema submit`) reports the PR ref, the
134
+ resolved title/body and where each came from, auto-merge/auto-fix state with
135
+ its source (flag vs `.haystack.json`), and the analysis outcome in the shared
136
+ verdict vocabulary.
137
+
78
138
  **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
139
 
80
140
  ```bash
@@ -145,6 +205,26 @@ haystack traces get owner/repo#42 <checkpoint-id> --cursor 20 --limit 20 --json
145
205
 
146
206
  These commands expose customer coding-session data, not Haystack's internal analysis-agent execution.
147
207
 
208
+ ### `haystack review`
209
+
210
+ 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`.
211
+
212
+ ```bash
213
+ haystack review 42 # Re-analyze and wait for the result
214
+ haystack review 42 --no-wait # Trigger and exit
215
+ haystack review 42 --json # Machine-readable outcome (schema: action)
216
+ ```
217
+
218
+ ### `haystack request-review`
219
+
220
+ Tag a PR as needing human review (adds `haystack:needs-review`), optionally requesting a specific GitHub user:
221
+
222
+ ```bash
223
+ haystack request-review 42 # Into the needs-assignment queue
224
+ haystack request-review 42 octocat # Also request review from octocat
225
+ haystack request-review 42 --json
226
+ ```
227
+
148
228
  ### `haystack dismiss`
149
229
 
150
230
  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 +232,7 @@ Dismiss analysis findings for a PR, moving it from "Issues Found" to "Good to Me
152
232
  ```bash
153
233
  haystack dismiss 42 # Dismiss findings for PR #42
154
234
  haystack dismiss acme/widgets#99 # Dismiss for specific repo
235
+ haystack dismiss 42 --json # Machine-readable (schema: action)
155
236
  ```
156
237
 
157
238
  ### `haystack undismiss`
@@ -205,10 +286,14 @@ haystack config wait-for-reviewers add cursor # Wait for Cursor BugBot
205
286
  haystack config wait-for-reviewers add cursor coderabbit # Add multiple
206
287
  haystack config wait-for-reviewers remove cursor # Stop waiting
207
288
  haystack config wait-for-reviewers clear # Wait for none
208
- # Also accepts raw GitHub bot usernames:
289
+ # Also accepts raw GitHub bot usernames (must end in [bot]):
209
290
  haystack config wait-for-reviewers add cursor-bugbot[bot]
210
291
  ```
211
292
 
293
+ Reviewer names are validated: anything that is neither a known friendly name
294
+ nor a `...[bot]` username is rejected (a stored typo would make the merge
295
+ queue wait forever for a bot that can't post).
296
+
212
297
  ### `haystack skills`
213
298
 
214
299
  Manage AI skills for your coding CLI:
@@ -0,0 +1,143 @@
1
+ # Map Your System for Haystack QA
2
+
3
+ Create `.haystack/system.yml` — a short file of facts about how this
4
+ repository's system works, so Haystack QA can run it.
5
+
6
+ ## Why this file exists
7
+
8
+ When Haystack reviews a risky PR, it doesn't just read the diff — it runs the
9
+ affected code in a sandbox and captures evidence. Haystack decides **what** to
10
+ test from each PR on its own. What it can't always infer is **how your system
11
+ works**: how to install and build it, how services boot, how to know they're
12
+ ready, how to seed data, how to act as a logged-in test user. That is what
13
+ this file provides. Every fact you add converts risks Haystack identified but
14
+ couldn't execute into checks that actually run.
15
+
16
+ ## Hard rules
17
+
18
+ 1. **Facts only — never test selection.** The file describes how the system
19
+ works. Keys like `scenarios`, `tests`, `skip`, or `focus` are rejected;
20
+ Haystack alone decides what to test.
21
+ 2. **Only verified facts.** Run every command yourself before declaring it.
22
+ A wrong fact is worse than a missing one — Haystack tracks which declared
23
+ facts fail and will flag them on PRs.
24
+ 3. **No secrets.** Reference environment variable *names* if needed, never
25
+ values. Anything that looks like a credential is rejected.
26
+ 4. **Personas are seeded FAKE users** reachable through dev/test-only login
27
+ paths. Never real accounts, never customer data. If a dev login helper
28
+ exists, it must be disabled in production builds.
29
+ 5. **Small and true beats big and aspirational.** Ten verified lines are more
30
+ useful than sixty guesses. Omit anything you could not verify.
31
+
32
+ ## Steps
33
+
34
+ ### 1. Inspect the repository
35
+
36
+ Determine, with evidence from the tree (lockfiles, manifests, scripts,
37
+ docker-compose, CI config, READMEs):
38
+
39
+ - package manager, install command, and any required build/codegen steps
40
+ - each runnable service: start command, port, and how "ready" is observable
41
+ (health endpoint, log line, or a command that exits 0)
42
+ - how to seed and reset local/test data
43
+ - how existing tests authenticate — is there a seeded test user or dev login?
44
+ - the test runner and any test helpers/factories generated tests should reuse
45
+ - external services the code calls, and how development runs without hitting
46
+ them for real (mock fixture, log driver, sandbox mode)
47
+ - where runtime logs are written
48
+
49
+ ### 2. Verify each fact by running it
50
+
51
+ From a clean state where possible: run the install, run the build, boot each
52
+ service and confirm its readiness signal, run the seed command, run the login
53
+ helper. Record the exact commands that worked. Drop anything you could not
54
+ make work — do not declare it with a caveat.
55
+
56
+ ### 3. Ask the developer only what you cannot determine
57
+
58
+ Typical questions worth asking a human: "Is there a dev-only way to log in as
59
+ a test user?", "Which services must be running for the API to work?", "Is
60
+ there a canonical seed script?". Keep it to the few facts you genuinely
61
+ cannot infer or verify.
62
+
63
+ ### 4. Write `.haystack/system.yml`
64
+
65
+ ```yaml
66
+ version: 1
67
+
68
+ environment:
69
+ setup:
70
+ - run: "pnpm install --frozen-lockfile"
71
+ - run: "pnpm codegen"
72
+ rationale: "generated types are imported throughout"
73
+ toolchain: { node: "20", pnpm: "10" }
74
+
75
+ services:
76
+ api:
77
+ root: "apps/api" # optional
78
+ boot: "pnpm dev"
79
+ ready: { http: "http://localhost:8080/health" } # or { log_pattern: "listening" } or { command: "pg_isready" }
80
+ env: { APP_ENV: "test" }
81
+ depends_on: [postgres]
82
+
83
+ data:
84
+ seed: "pnpm db:seed --profile=qa"
85
+ reset: "pnpm db:reset"
86
+ personas:
87
+ admin:
88
+ login: "pnpm test:login admin"
89
+ yields: "prints an Authorization header on stdout"
90
+
91
+ observability:
92
+ logs: ["apps/api/log/dev.log"]
93
+ inspect_db: "pnpm db:console --readonly"
94
+
95
+ external_services:
96
+ stripe:
97
+ offline: "repo ships a stripe-mock fixture used by tests"
98
+ must_not_touch_live: true
99
+
100
+ testing:
101
+ runner: "pnpm vitest"
102
+ probe_helpers: "tests/helpers/ has request builders and DB factories"
103
+
104
+ constraints:
105
+ - "full build takes ~6 min; apps/api boots standalone without the web build"
106
+ ```
107
+
108
+ Every section is optional — include only what you verified. All commands run
109
+ from the repo root unless a `cwd`/`root` says otherwise.
110
+
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:
120
+
121
+ - [ ] every `run`/`boot`/`login`/`seed` command was executed and worked
122
+ - [ ] every service has a `ready` signal you observed
123
+ - [ ] personas are seeded fake users via dev/test-only paths
124
+
125
+ Haystack re-validates the file on its next QA run and reports precise errors
126
+ if something is wrong.
127
+
128
+ ### 6. Open a small, reviewable PR
129
+
130
+ Add only `.haystack/system.yml` (plus a dev login helper if one was genuinely
131
+ needed — dev/test-gated, with a test proving it's off in production). In the
132
+ PR description, list which facts you verified by running them and which came
133
+ from the developer's answers.
134
+
135
+ ## What happens next
136
+
137
+ Haystack treats each entry as a claim. When a QA run executes a declared
138
+ command successfully, the fact is marked **verified** with the run's receipt;
139
+ if it fails at a newer commit it is marked **broken**, and Haystack will ask
140
+ about it on a PR instead of trusting it. When QA hits something the map
141
+ doesn't cover, it asks at most a couple of targeted questions on the affected
142
+ PR, each with a ready-to-paste snippet — answering them grows this file over
143
+ time.
@@ -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
  }