@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
@@ -7,6 +7,7 @@
7
7
  * Analysis is triggered automatically by the GitHub App webhook when a PR
8
8
  * is created or updated — the CLI does not need to trigger it directly.
9
9
  */
10
+ import { classifyHttpError } from './haystack-api.js';
10
11
  // ============================================================================
11
12
  // Constants
12
13
  // ============================================================================
@@ -61,9 +62,114 @@ function apiHeaders(token) {
61
62
  }
62
63
  return headers;
63
64
  }
64
- // ============================================================================
65
- // API functions
66
- // ============================================================================
65
+ /**
66
+ * Trigger a fresh full analysis for a PR via the auth-worker
67
+ * (`/api/analysis-trigger/{owner}/{repo}/{pr}`). Requires auth — triggers
68
+ * start a real analysis run. Returns the request id to wait on.
69
+ */
70
+ export async function triggerAnalysis(owner, repo, prNumber, token, headSha) {
71
+ const url = `${ANALYSIS_API_BASE}/api/analysis-trigger/${owner}/${repo}/${prNumber}`;
72
+ let response;
73
+ try {
74
+ // Force semantics ride on a server-set header at the trigger route.
75
+ // commitSha matters for dedup: without it, ingestion matches this
76
+ // request onto any in-flight run for an OLDER head and the CLI would
77
+ // wait on (and render) stale results.
78
+ response = await fetch(url, {
79
+ method: 'POST',
80
+ headers: { ...apiHeaders(token), 'Content-Type': 'application/json' },
81
+ body: JSON.stringify(headSha ? { commitSha: headSha } : {}),
82
+ });
83
+ }
84
+ catch (err) {
85
+ return { status: 'error', message: err.message };
86
+ }
87
+ let data = {};
88
+ let parseFailed = false;
89
+ try {
90
+ data = await response.json();
91
+ }
92
+ catch {
93
+ // Recorded, not swallowed: for non-OK responses the status-code handling
94
+ // below surfaces the failure; a 2xx with an unparseable body is its own
95
+ // explicit error (never "empty data") so callers can't mistake a
96
+ // malformed response for a normal one.
97
+ parseFailed = true;
98
+ }
99
+ if (parseFailed && response.ok) {
100
+ return { status: 'error', message: `Trigger endpoint returned invalid JSON (HTTP ${response.status})` };
101
+ }
102
+ if (response.status === 429) {
103
+ return {
104
+ status: 'rate_limited',
105
+ message: data.message || 'Too many analysis requests. Please wait before trying again.',
106
+ retryAfterSeconds: data.retryAfter,
107
+ };
108
+ }
109
+ if (response.status === 401 || response.status === 403) {
110
+ return {
111
+ status: 'denied',
112
+ message: data.message || data.error || `HTTP ${response.status}`,
113
+ };
114
+ }
115
+ if (!response.ok) {
116
+ return { status: 'error', message: data.message || data.error || `HTTP ${response.status}` };
117
+ }
118
+ if (!data.request_id) {
119
+ return { status: 'error', message: 'Trigger response did not include a request id' };
120
+ }
121
+ if (data.status === 'already_processing') {
122
+ return { status: 'already_processing', requestId: data.request_id };
123
+ }
124
+ return { status: 'started', requestId: data.request_id };
125
+ }
126
+ // Terminal states of the v3 status vocabulary (map_status_for_frontend in
127
+ // haystack-v3-request-status). Everything else — QUEUED, PROCESSING,
128
+ // CLONING, PREPARING, ANALYZING, not_found (row lag), and any state a newer
129
+ // backend introduces — reads as pending: misclassifying a healthy
130
+ // in-progress state as an error would abort the wait.
131
+ const TERMINAL_STATUSES = {
132
+ COMPLETED: 'COMPLETED',
133
+ FAILED: 'FAILED',
134
+ TIMED_OUT: 'FAILED',
135
+ SUPERSEDED: 'SUPERSEDED',
136
+ };
137
+ /**
138
+ * Poll the status of a triggered run by request id. The PR-level result
139
+ * endpoint can't distinguish a fresh re-run on the same head from the
140
+ * previous completed analysis — the request id can.
141
+ */
142
+ export async function fetchTriggerStatus(requestId, token) {
143
+ const url = `${ANALYSIS_API_BASE}/api/analysis-trigger/status/${requestId}`;
144
+ try {
145
+ const response = await fetch(url, { headers: apiHeaders(token) });
146
+ // The status Lambda returns not_found as a 404 WITH a JSON body — parse
147
+ // before rejecting on status code so "not yet written" is distinguishable
148
+ // from a real transport failure.
149
+ let data = {};
150
+ try {
151
+ data = await response.json();
152
+ }
153
+ catch {
154
+ return { status: 'error', message: `HTTP ${response.status}` };
155
+ }
156
+ const status = typeof data.status === 'string' ? data.status : '';
157
+ const terminal = TERMINAL_STATUSES[status];
158
+ if (terminal) {
159
+ return { status: terminal, message: data.message };
160
+ }
161
+ // Only successful responses (plus the documented 404 not_found row-lag
162
+ // case) read as pending — a 5xx carrying {"status":"error"} must count
163
+ // toward the error budget, not spin the wait for the full timeout.
164
+ if (response.ok || (response.status === 404 && status === 'not_found')) {
165
+ return { status: 'pending', message: data.message };
166
+ }
167
+ return { status: 'error', message: data.message || `HTTP ${response.status}` };
168
+ }
169
+ catch (err) {
170
+ return { status: 'error', message: err.message };
171
+ }
172
+ }
67
173
  /**
68
174
  * Check if analysis results are available for a PR by polling the result endpoint.
69
175
  * Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
@@ -82,7 +188,9 @@ export async function checkAnalysisReady(owner, repo, prNumber, token) {
82
188
  if (response.status === 404)
83
189
  return { status: 'pending' };
84
190
  if (!response.ok) {
85
- return { status: 'error', message: `HTTP ${response.status}` };
191
+ // Classified, actionable message — a bare "HTTP 403" used to send agents
192
+ // into a retry loop when the fix was `haystack login`.
193
+ return { status: 'error', message: (await classifyHttpError(response, 'Analysis status check')).message };
86
194
  }
87
195
  let data;
88
196
  try {
@@ -231,12 +339,22 @@ export async function fetchAnalysisResults(owner, repo, prNumber, token) {
231
339
  needsHumanReview,
232
340
  };
233
341
  }
342
+ /**
343
+ * Null means "artifact does not exist (yet)" — 404s and transient
344
+ * network/parse failures. Auth failures (401/403) THROW a classified
345
+ * HaystackApiError instead: collapsing them into null used to make an
346
+ * expired token read as "not available yet — try again in a moment",
347
+ * sending agents into a retry loop when the fix was `haystack login`.
348
+ */
234
349
  export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
235
350
  const url = v3ResultUrl(owner, repo, prNumber, 'auto-fix-manifest.json');
236
351
  try {
237
352
  const response = await fetch(url, {
238
353
  headers: apiHeaders(token),
239
354
  });
355
+ if (response.status === 401 || response.status === 403) {
356
+ throw await classifyHttpError(response, 'Auto-fix manifest fetch');
357
+ }
240
358
  if (!response.ok)
241
359
  return null;
242
360
  const data = await response.json();
@@ -250,10 +368,17 @@ export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
250
368
  }
251
369
  return data;
252
370
  }
253
- catch {
371
+ catch (err) {
372
+ if (err instanceof Error && err.name === 'HaystackApiError')
373
+ throw err;
374
+ // Transient network/parse failure — same "not available" semantics as 404.
254
375
  return null;
255
376
  }
256
377
  }
378
+ /**
379
+ * Null means "synthesis does not exist (yet)". Auth failures (401/403) THROW
380
+ * a classified HaystackApiError — see fetchAutoFixManifest for why.
381
+ */
257
382
  export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
258
383
  // Route through the auth-worker (/api/analysis/{prId}/rating_synthesis.json)
259
384
  // so hsk_live tokens are introspected and the scope gate applies — see
@@ -263,6 +388,9 @@ export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
263
388
  const response = await fetch(url, {
264
389
  headers: apiHeaders(token),
265
390
  });
391
+ if (response.status === 401 || response.status === 403) {
392
+ throw await classifyHttpError(response, 'Rating synthesis fetch');
393
+ }
266
394
  if (!response.ok)
267
395
  return null;
268
396
  const data = await response.json();
@@ -279,7 +407,10 @@ export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
279
407
  }
280
408
  return data;
281
409
  }
282
- catch {
410
+ catch (err) {
411
+ if (err instanceof Error && err.name === 'HaystackApiError')
412
+ throw err;
413
+ // Transient network/parse failure — same "not available" semantics as 404.
283
414
  return null;
284
415
  }
285
416
  }
@@ -399,6 +399,13 @@ export async function resolveAuthContext(options = {}) {
399
399
  // — that intent should not be overridden by a stale OAuth cookie.
400
400
  const headless = await readHeadlessToken();
401
401
  if (headless) {
402
+ // The headless token wins, but dropping an explicit --account silently
403
+ // would leave the caller authenticated as someone they didn't ask for —
404
+ // say so on stderr.
405
+ if (options.preferredLogin) {
406
+ console.error(`--account ${options.preferredLogin} is ignored: a headless token (${headless.label ?? 'token file'}) takes precedence. ` +
407
+ 'Unset HAYSTACK_CLI_TOKEN or remove the token file (`haystack logout --headless`) to use saved accounts.');
408
+ }
402
409
  return {
403
410
  login: headless.label ?? 'headless',
404
411
  token: headless.token,
@@ -78,7 +78,10 @@ export declare class UnsupportedRemoteUrlError extends Error {
78
78
  */
79
79
  export declare function parseRemoteUrl(remoteName?: string): GitRemoteInfo;
80
80
  /**
81
- * Get the last commit message (first line only)
81
+ * Get the last commit message (first line only).
82
+ *
83
+ * Throws instead of guessing: this feeds the default PR title, and the old
84
+ * silent fallback ('Update') shipped meaningless titles when git failed.
82
85
  */
83
86
  export declare function getLastCommitMessage(): string;
84
87
  /**
package/dist/utils/git.js CHANGED
@@ -22,8 +22,10 @@ export function getCurrentBranch() {
22
22
  stdio: ['pipe', 'pipe', 'pipe'],
23
23
  }).trim();
24
24
  }
25
- catch {
26
- throw new Error('Failed to get current branch. Are you in a git repository?');
25
+ catch (err) {
26
+ const stderr = err?.stderr?.toString().trim();
27
+ throw new Error(`Failed to get the current branch${stderr ? ` (${stderr})` : ''}. ` +
28
+ 'Run from inside a git repository.');
27
29
  }
28
30
  }
29
31
  /**
@@ -296,19 +298,25 @@ export function parseRemoteUrl(remoteName = 'origin') {
296
298
  }
297
299
  catch (err) {
298
300
  // Propagate the "unrecognized URL" case as-is; wrap everything else (the
299
- // `git remote get-url` command itself failing) in a friendlier message.
301
+ // `git remote get-url` command itself failing) with the underlying git
302
+ // error preserved — "No such remote 'origin'" is the actual diagnosis.
300
303
  // Classify by type, not message text (see pr-rules.yml PR007).
301
304
  if (err instanceof UnsupportedRemoteUrlError) {
302
305
  throw err;
303
306
  }
304
- throw new Error(`Failed to get remote URL for '${remoteName}'`);
307
+ const stderr = err?.stderr?.toString().trim();
308
+ const cause = stderr || (err instanceof Error ? err.message : String(err));
309
+ throw new Error(`Failed to get remote URL for '${remoteName}': ${cause}`);
305
310
  }
306
311
  }
307
312
  // ============================================================================
308
313
  // Commit operations
309
314
  // ============================================================================
310
315
  /**
311
- * Get the last commit message (first line only)
316
+ * Get the last commit message (first line only).
317
+ *
318
+ * Throws instead of guessing: this feeds the default PR title, and the old
319
+ * silent fallback ('Update') shipped meaningless titles when git failed.
312
320
  */
313
321
  export function getLastCommitMessage() {
314
322
  try {
@@ -317,8 +325,10 @@ export function getLastCommitMessage() {
317
325
  stdio: ['pipe', 'pipe', 'pipe'],
318
326
  }).trim();
319
327
  }
320
- catch {
321
- return 'Update';
328
+ catch (err) {
329
+ const stderr = err?.stderr?.toString().trim();
330
+ throw new Error(`Could not read the last commit message${stderr ? ` (${stderr})` : ''}. ` +
331
+ 'Commit your changes first, or pass an explicit --title.');
322
332
  }
323
333
  }
324
334
  /**
@@ -12,6 +12,21 @@ export interface CliIdentity {
12
12
  login: string;
13
13
  id: number;
14
14
  }
15
+ /**
16
+ * Typed error for non-OK Haystack API responses. Callers classify by
17
+ * `status` (never by message substring) and the message always carries a
18
+ * remediation an agent can act on.
19
+ */
20
+ export declare class HaystackApiError extends Error {
21
+ readonly status: number;
22
+ constructor(status: number, message: string);
23
+ get isAuthError(): boolean;
24
+ }
25
+ /**
26
+ * Build an actionable error for a non-OK response: what failed, the HTTP
27
+ * class it failed with, and the next command to run.
28
+ */
29
+ export declare function classifyHttpError(response: Response, context: string): Promise<HaystackApiError>;
15
30
  export declare function haystackJson<T>(path: string, token: string, init?: RequestInit): Promise<T>;
16
31
  export declare function fetchCliIdentity(token: string): Promise<CliIdentity>;
17
32
  export declare function fetchHaystackInstallations(token: string): Promise<UserInstallation[]>;
@@ -1,4 +1,20 @@
1
1
  const HAYSTACK_API_BASE = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
2
+ /**
3
+ * Typed error for non-OK Haystack API responses. Callers classify by
4
+ * `status` (never by message substring) and the message always carries a
5
+ * remediation an agent can act on.
6
+ */
7
+ export class HaystackApiError extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.name = 'HaystackApiError';
12
+ this.status = status;
13
+ }
14
+ get isAuthError() {
15
+ return this.status === 401 || this.status === 403;
16
+ }
17
+ }
2
18
  function headers(token, extra) {
3
19
  const result = new Headers(extra);
4
20
  result.set('Authorization', `Bearer ${token}`);
@@ -6,17 +22,52 @@ function headers(token, extra) {
6
22
  result.set('User-Agent', 'Haystack-CLI');
7
23
  return result;
8
24
  }
9
- async function errorMessage(response) {
10
- const text = await response.text().catch(() => '');
25
+ /** Extract the server's own message if the body is JSON; truncate raw bodies. */
26
+ async function serverMessage(response) {
27
+ let text;
28
+ try {
29
+ text = await response.text();
30
+ }
31
+ catch (err) {
32
+ // A transport failure while reading the error body is itself signal —
33
+ // say so instead of reporting it as "no detail" (the status-based
34
+ // classification in classifyHttpError stands either way).
35
+ return `error body unreadable: ${err instanceof Error ? err.message : String(err)}`;
36
+ }
11
37
  if (!text)
12
- return `HTTP ${response.status}`;
38
+ return null;
13
39
  try {
14
40
  const parsed = JSON.parse(text);
15
- return parsed.message ?? parsed.error ?? text;
41
+ return parsed.message ?? parsed.error ?? null;
16
42
  }
17
43
  catch {
18
- return text;
44
+ // Not JSON (HTML error page, plain text) — never dump a full raw body.
45
+ return text.slice(0, 200);
46
+ }
47
+ }
48
+ /**
49
+ * Build an actionable error for a non-OK response: what failed, the HTTP
50
+ * class it failed with, and the next command to run.
51
+ */
52
+ export async function classifyHttpError(response, context) {
53
+ const detail = await serverMessage(response);
54
+ const suffix = detail ? ` (${detail})` : '';
55
+ if (response.status === 401) {
56
+ return new HaystackApiError(401, `${context}: authentication failed${suffix}. Run \`haystack login\` to re-authenticate.`);
57
+ }
58
+ if (response.status === 403) {
59
+ return new HaystackApiError(403, `${context}: access denied${suffix}. Your token may lack access to this repository — run \`haystack auth list\` to check accounts, or \`haystack login\` to add one.`);
60
+ }
61
+ if (response.status === 404) {
62
+ return new HaystackApiError(404, `${context}: not found${suffix}. Check the PR number and repository.`);
63
+ }
64
+ if (response.status === 429) {
65
+ return new HaystackApiError(429, `${context}: rate limited${suffix}. Wait before retrying.`);
66
+ }
67
+ if (response.status >= 500) {
68
+ return new HaystackApiError(response.status, `${context}: Haystack server error (HTTP ${response.status})${suffix}. Retry in a moment.`);
19
69
  }
70
+ return new HaystackApiError(response.status, `${context}: unexpected response (HTTP ${response.status})${suffix}.`);
20
71
  }
21
72
  export async function haystackJson(path, token, init = {}) {
22
73
  const response = await fetch(`${HAYSTACK_API_BASE}${path}`, {
@@ -24,7 +75,7 @@ export async function haystackJson(path, token, init = {}) {
24
75
  headers: headers(token, init.headers),
25
76
  });
26
77
  if (!response.ok) {
27
- throw new Error(`${response.status} ${await errorMessage(response)}`);
78
+ throw await classifyHttpError(response, `Haystack API ${path}`);
28
79
  }
29
80
  return response.json();
30
81
  }
@@ -46,7 +97,7 @@ export async function fetchAnalysisArtifact(owner, repo, prNumber, file, token)
46
97
  if (response.status === 404)
47
98
  return null;
48
99
  if (!response.ok) {
49
- throw new Error(`${response.status} ${await errorMessage(response)}`);
100
+ throw await classifyHttpError(response, `Analysis artifact ${file}`);
50
101
  }
51
102
  const data = await response.json();
52
103
  if (!data || typeof data !== 'object' || !('download_url' in data) || !data.download_url) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared PR-identifier parsing for every command that takes a PR ref.
3
+ *
4
+ * One grammar, one error message, one repo-inference path. This used to be
5
+ * duplicated per command, and the copies drifted: different "accepted formats"
6
+ * lists (none mentioned the `#123` form the regex accepted), and one copy let
7
+ * a raw git error escape where the others gave a friendly hint. Commands must
8
+ * import from here rather than reimplementing the grammar.
9
+ *
10
+ * Accepted formats, in the order they are tried:
11
+ * https://github.com/owner/repo/pull/123 GitHub URL
12
+ * owner/repo#123 Fully qualified
13
+ * 123 or #123 PR number (repo inferred from the `origin` remote)
14
+ */
15
+ export interface ParsedPR {
16
+ owner: string;
17
+ repo: string;
18
+ prNumber: number;
19
+ }
20
+ /** The accepted-formats block used in parse errors and help text. */
21
+ export declare function acceptedPrRefFormats(commandName: string): string;
22
+ /**
23
+ * Parse a PR identifier. `commandName` is interpolated into error messages so
24
+ * the agent can copy a corrected invocation verbatim (e.g. "triage",
25
+ * "pr get", "traces list").
26
+ */
27
+ export declare function parsePrRef(identifier: string, commandName: string): ParsedPR;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared PR-identifier parsing for every command that takes a PR ref.
3
+ *
4
+ * One grammar, one error message, one repo-inference path. This used to be
5
+ * duplicated per command, and the copies drifted: different "accepted formats"
6
+ * lists (none mentioned the `#123` form the regex accepted), and one copy let
7
+ * a raw git error escape where the others gave a friendly hint. Commands must
8
+ * import from here rather than reimplementing the grammar.
9
+ *
10
+ * Accepted formats, in the order they are tried:
11
+ * https://github.com/owner/repo/pull/123 GitHub URL
12
+ * owner/repo#123 Fully qualified
13
+ * 123 or #123 PR number (repo inferred from the `origin` remote)
14
+ */
15
+ import { parseRemoteUrl } from './git.js';
16
+ /** The accepted-formats block used in parse errors and help text. */
17
+ export function acceptedPrRefFormats(commandName) {
18
+ return (` haystack ${commandName} 123\n` +
19
+ ` haystack ${commandName} #123\n` +
20
+ ` haystack ${commandName} owner/repo#123\n` +
21
+ ` haystack ${commandName} https://github.com/owner/repo/pull/123`);
22
+ }
23
+ /**
24
+ * Parse a PR identifier. `commandName` is interpolated into error messages so
25
+ * the agent can copy a corrected invocation verbatim (e.g. "triage",
26
+ * "pr get", "traces list").
27
+ */
28
+ export function parsePrRef(identifier, commandName) {
29
+ const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
30
+ if (urlMatch) {
31
+ return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
32
+ }
33
+ // owner/repo#123 — repo segment may not contain `/` (a stray extra segment
34
+ // used to silently become part of the repo name and 404 downstream).
35
+ const qualifiedMatch = identifier.match(/^([^/#\s]+)\/([^/#\s]+)#(\d+)$/);
36
+ if (qualifiedMatch) {
37
+ return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
38
+ }
39
+ const numberMatch = identifier.match(/^#?(\d+)$/);
40
+ if (numberMatch) {
41
+ const prNumber = parseInt(numberMatch[1], 10);
42
+ try {
43
+ const remote = parseRemoteUrl('origin');
44
+ return { owner: remote.owner, repo: remote.repo, prNumber };
45
+ }
46
+ catch (err) {
47
+ throw new Error(`Cannot determine the repository for PR #${prNumber} ` +
48
+ `(${err instanceof Error ? err.message : String(err)}).\n` +
49
+ `Run from inside a checkout with an "origin" remote, or use the full format:\n` +
50
+ ` haystack ${commandName} owner/repo#${prNumber}`);
51
+ }
52
+ }
53
+ throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
54
+ `Accepted formats:\n${acceptedPrRefFormats(commandName)}\n\n` +
55
+ `See \`haystack ${commandName.split(' ')[0]} --help\` for details.`);
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.18",
3
+ "version": "0.15.20",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/action.v1.json",
4
+ "title": "haystack <dismiss|mark-reviewed|undismiss|request-review|review> --json",
5
+ "description": "Shared envelope for every mutating command. Exactly one document on stdout; progress on stderr. status 'error' payloads accompany exit code 1.",
6
+ "type": "object",
7
+ "required": ["schema_version", "action", "status"],
8
+ "properties": {
9
+ "schema_version": { "type": "string", "const": "1.0.0" },
10
+ "action": { "type": "string", "enum": ["dismiss", "mark_reviewed", "undismiss", "request_review", "trigger_review", "clear_pending"] },
11
+ "status": { "type": "string", "enum": ["ok", "error"] },
12
+ "ref": { "type": ["string", "null"], "description": "owner/repo#number. Null on errors that happen before the ref resolves." },
13
+ "error": { "type": "string", "description": "Present only when status is 'error'. Actionable message including the remediation." },
14
+ "detail": { "type": "string", "description": "Human-readable statement of what changed." },
15
+ "head_sha": { "type": "string", "description": "dismiss/mark_reviewed: the head commit the override is tied to. A new commit invalidates the override." },
16
+ "reviewer": { "type": ["string", "null"], "description": "request_review: the GitHub user review was requested from, or null for the needs-assignment queue." },
17
+ "reviewer_request_failed": { "type": ["string", "null"], "description": "request_review: set when the label was applied but the specific-reviewer request failed (partial success)." },
18
+ "request_id": { "type": "string", "description": "trigger_review: the analysis run id." },
19
+ "run_status": { "type": "string", "enum": ["started", "already_processing", "completed", "superseded", "running"], "description": "trigger_review: state of the triggered run when the command returned." },
20
+ "next": { "type": ["string", "null"], "description": "Follow-up command to read results (e.g. `haystack triage <ref> --json`)." }
21
+ }
22
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/error.v1.json",
4
+ "title": "Generic --json error envelope",
5
+ "description": "Emitted on stdout (with exit code 1) when a read command invoked with --json fails before it can produce its own payload. Commands with richer contracts (submit, action) embed status:'error' in their own schemas instead.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "status", "error"],
9
+ "properties": {
10
+ "schema_version": { "type": "string", "const": "1.0.0" },
11
+ "status": { "type": "string", "const": "error" },
12
+ "error": { "type": "string", "description": "Actionable message including the remediation." }
13
+ }
14
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/pr-status.v1.json",
4
+ "title": "haystack pr-status <ref> --json",
5
+ "description": "Which Haystack feed bucket a PR is in, and why. All state tokens are snake_case — the same vocabulary as `haystack triage --json` and `haystack pr get --json`.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "ref", "bucket", "in_feed", "feed_priority", "reason", "analysis", "needs_human_review", "labels", "overrides", "author", "is_author", "is_open", "state_synced_at", "stale_warning"],
9
+ "properties": {
10
+ "schema_version": { "type": "string", "const": "1.0.0" },
11
+ "ref": { "type": "string", "description": "owner/repo#number" },
12
+ "bucket": {
13
+ "type": ["string", "null"],
14
+ "description": "Feed bucket in snake_case. Known values are enumerated below; new buckets may appear (always snake_case). Null when the PR is unclassified (closed or not yet analyzed).",
15
+ "examples": ["analyzing", "auto_fixing", "good_to_merge", "failed_when_merging", "issues", "needs_assignment", "needs_shepherding", "needs_fixing", "awaiting_review", "review_requested", "snoozed"]
16
+ },
17
+ "in_feed": { "type": ["boolean", "null"], "description": "Null when the PR is unclassified (e.g. closed)." },
18
+ "feed_priority": { "type": ["number", "null"] },
19
+ "reason": { "type": "string", "description": "Human-readable explanation of the classification." },
20
+ "analysis": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "required": ["status", "verdict", "rating"],
24
+ "properties": {
25
+ "status": { "type": "string", "enum": ["pending", "in_queue", "ready", "error"] },
26
+ "verdict": {
27
+ "anyOf": [
28
+ { "type": "string", "enum": ["good_to_merge", "needs_review", "needs_input"] },
29
+ { "type": "null" }
30
+ ],
31
+ "description": "Null until analysis is ready."
32
+ },
33
+ "rating": {
34
+ "anyOf": [
35
+ { "type": "integer", "minimum": 1, "maximum": 5 },
36
+ { "type": "null" }
37
+ ]
38
+ }
39
+ }
40
+ },
41
+ "needs_human_review": { "type": ["boolean", "null"] },
42
+ "labels": { "type": "array", "items": { "type": "string" } },
43
+ "overrides": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": ["snoozed", "dismissed", "dismissed_agent_alerts", "dismissed_needs_assignment", "manually_good_to_merge"],
47
+ "properties": {
48
+ "snoozed": { "type": "boolean" },
49
+ "dismissed": { "type": "boolean" },
50
+ "dismissed_agent_alerts": { "type": "boolean" },
51
+ "dismissed_needs_assignment": { "type": "boolean" },
52
+ "manually_good_to_merge": { "type": "boolean" }
53
+ }
54
+ },
55
+ "author": { "type": ["string", "null"], "description": "Null when the server could not resolve PR metadata." },
56
+ "is_author": { "type": ["boolean", "null"] },
57
+ "is_open": { "type": ["boolean", "null"] },
58
+ "state_synced_at": { "type": ["string", "null"] },
59
+ "stale_warning": { "type": ["string", "null"] }
60
+ }
61
+ }