@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.
- package/README.md +86 -1
- package/dist/assets/skills/map-your-system.md +143 -0
- package/dist/commands/ask.js +2 -2
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +46 -14
- package/dist/commands/dismiss.d.ts +15 -3
- package/dist/commands/dismiss.js +66 -116
- package/dist/commands/mcp.js +96 -1
- package/dist/commands/policy.js +31 -12
- package/dist/commands/pr-status.d.ts +125 -2
- package/dist/commands/pr-status.js +88 -73
- package/dist/commands/pr.d.ts +1 -7
- package/dist/commands/pr.js +10 -21
- package/dist/commands/request-review.d.ts +12 -1
- package/dist/commands/request-review.js +42 -65
- package/dist/commands/review.d.ts +25 -0
- package/dist/commands/review.js +191 -0
- package/dist/commands/setup.js +4 -1
- package/dist/commands/skills.js +4 -0
- package/dist/commands/submit.d.ts +3 -0
- package/dist/commands/submit.js +178 -38
- package/dist/commands/system-map.d.ts +42 -0
- package/dist/commands/system-map.js +228 -0
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/traces.js +2 -2
- package/dist/commands/triage.d.ts +1 -6
- package/dist/commands/triage.js +51 -55
- package/dist/index.js +116 -19
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +4 -0
- package/dist/states.d.ts +29 -0
- package/dist/states.js +46 -0
- package/dist/triage/runner.d.ts +7 -0
- package/dist/triage/runner.js +2 -2
- package/dist/utils/action-output.d.ts +24 -0
- package/dist/utils/action-output.js +26 -0
- package/dist/utils/analysis-api.d.ts +44 -0
- package/dist/utils/analysis-api.js +137 -6
- package/dist/utils/auth.js +7 -0
- package/dist/utils/git.d.ts +4 -1
- package/dist/utils/git.js +17 -7
- package/dist/utils/haystack-api.d.ts +15 -0
- package/dist/utils/haystack-api.js +58 -7
- package/dist/utils/pr-ref.d.ts +27 -0
- package/dist/utils/pr-ref.js +56 -0
- package/package.json +1 -1
- package/schemas/action.v1.json +22 -0
- package/schemas/error.v1.json +14 -0
- package/schemas/pr-status.v1.json +61 -0
- package/schemas/pr.v1.json +80 -17
- package/schemas/setup.v1.json +66 -12
- package/schemas/submit.v1.json +88 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/utils/auth.js
CHANGED
|
@@ -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,
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
38
|
+
return null;
|
|
13
39
|
try {
|
|
14
40
|
const parsed = JSON.parse(text);
|
|
15
|
-
return parsed.message ?? parsed.error ??
|
|
41
|
+
return parsed.message ?? parsed.error ?? null;
|
|
16
42
|
}
|
|
17
43
|
catch {
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
+
}
|