@haystackeditor/cli 0.25.1 → 0.27.0
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 +39 -463
- package/dist/capture/app-config.js +30 -6
- package/dist/commands/capture-brief.js +45 -35
- package/dist/commands/capture-contract.js +4 -4
- package/dist/commands/crawl-report.js +196 -0
- package/dist/commands/db-profile-upload.js +3 -3
- package/dist/commands/feedback.js +66 -0
- package/dist/commands/init-telemetry.js +55 -9
- package/dist/commands/init.js +8 -3
- package/dist/commands/lockfile-pin.js +307 -0
- package/dist/commands/telemetry-token.js +3 -3
- package/dist/commands/tokens.js +4 -4
- package/dist/commands/verify-explore.js +3 -3
- package/dist/commands/verify-history.js +2 -2
- package/dist/commands/verify-onboarding.js +13 -16
- package/dist/commands/verify-precompute.js +3 -4
- package/dist/commands/verify.js +31 -118
- package/dist/index.js +55 -968
- package/dist/schema.js +4 -10
- package/dist/utils/haystack-api.js +8 -36
- package/package.json +1 -5
- package/schemas/feedback.v1.json +13 -0
- package/schemas/pre-verify.v2.json +240 -0
- package/schemas/verify-raw.v1.json +1132 -0
- package/schemas/verify.v2.json +655 -0
- package/dist/assets/hooks/agent-context/detect.ts +0 -316
- package/dist/assets/hooks/agent-context/format.ts +0 -100
- package/dist/assets/hooks/agent-context/index.ts +0 -41
- package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
- package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
- package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
- package/dist/assets/hooks/agent-context/types.ts +0 -58
- package/dist/assets/hooks/llm-rules-template.md +0 -59
- package/dist/assets/hooks/package-lock.json +0 -598
- package/dist/assets/hooks/package.json +0 -12
- package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
- package/dist/assets/hooks/scripts/post-commit.sh +0 -5
- package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
- package/dist/assets/hooks/scripts/pre-push.sh +0 -25
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
- package/dist/assets/hooks/truncation-checker/index.ts +0 -595
- package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
- package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
- package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
- package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
- package/dist/assets/skills/map-your-system.md +0 -143
- package/dist/assets/skills/submit.md +0 -200
- package/dist/commands/ask.js +0 -20
- package/dist/commands/cloud-verifier-behaviors.js +0 -218
- package/dist/commands/cloud-verifier-data-store-census.js +0 -539
- package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
- package/dist/commands/cloud-verifier-identity-census.js +0 -4060
- package/dist/commands/cloud-verifier-materialization.js +0 -704
- package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
- package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
- package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
- package/dist/commands/cloud-verifier-universe.js +0 -10178
- package/dist/commands/config.js +0 -549
- package/dist/commands/design-verify.js +0 -311
- package/dist/commands/dismiss.js +0 -159
- package/dist/commands/hooks.js +0 -226
- package/dist/commands/inbox.js +0 -137
- package/dist/commands/mcp.js +0 -201
- package/dist/commands/policy.js +0 -371
- package/dist/commands/pr-status.js +0 -207
- package/dist/commands/pr.js +0 -105
- package/dist/commands/prepare-universe-review.js +0 -1092
- package/dist/commands/production-source-deny-policy.js +0 -100
- package/dist/commands/request-review.js +0 -74
- package/dist/commands/review.js +0 -191
- package/dist/commands/rules.js +0 -98
- package/dist/commands/scaffold-provisional-universe.js +0 -806
- package/dist/commands/setup.js +0 -1170
- package/dist/commands/skills.js +0 -447
- package/dist/commands/status.js +0 -35
- package/dist/commands/submit.js +0 -745
- package/dist/commands/system-map.js +0 -228
- package/dist/commands/triage.js +0 -598
- package/dist/commands/webhooks.js +0 -241
- package/dist/states.js +0 -46
- package/dist/tools/detect.js +0 -832
- package/dist/triage/astra.js +0 -202
- package/dist/triage/prompts.js +0 -188
- package/dist/triage/runner.js +0 -200
- package/dist/triage/types.js +0 -7
- package/dist/types.js +0 -326
- package/dist/utils/action-output.js +0 -26
- package/dist/utils/analysis-api.js +0 -416
- package/dist/utils/config.js +0 -54
- package/dist/utils/design-verifier-api.js +0 -294
- package/dist/utils/design-verifier-history.js +0 -79
- package/dist/utils/design-verifier-result.js +0 -424
- package/dist/utils/github-api.js +0 -324
- package/dist/utils/pending-state.js +0 -86
- package/dist/utils/pr-ref.js +0 -56
- package/dist/utils/prompter.js +0 -328
- package/schemas/action.v1.json +0 -22
- package/schemas/ask.v1.json +0 -40
- package/schemas/inbox.v1.json +0 -27
- package/schemas/pr-status.v1.json +0 -61
- package/schemas/pr.v1.json +0 -97
- package/schemas/pr.v3.json +0 -45
- package/schemas/setup.v1.json +0 -75
- package/schemas/submit.v1.json +0 -90
- package/schemas/triage.v1.json +0 -103
- package/schemas/triage.v2.json +0 -64
package/dist/commands/triage.js
DELETED
|
@@ -1,598 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* triage command — Show Haystack analysis results for a PR.
|
|
3
|
-
*
|
|
4
|
-
* Designed for coding agents to consume. Shows two things:
|
|
5
|
-
* 1. What the auto-fixer is already handling (so the agent doesn't duplicate work)
|
|
6
|
-
* 2. Findings with attribution and fix prompts (so the agent knows what to fix)
|
|
7
|
-
*
|
|
8
|
-
* When called without a PR identifier, reads the last submitted PR from
|
|
9
|
-
* `.haystack/pending-submit.json` (written by `haystack submit`).
|
|
10
|
-
*
|
|
11
|
-
* Accepts a PR identifier in several formats:
|
|
12
|
-
* haystack triage # Last submitted PR
|
|
13
|
-
* haystack triage 123 # Uses current repo's owner/repo
|
|
14
|
-
* haystack triage owner/repo#123 # Fully qualified
|
|
15
|
-
* haystack triage https://github.com/owner/repo/pull/123
|
|
16
|
-
*
|
|
17
|
-
* Output modes:
|
|
18
|
-
* --hook Minimal single-line for session-start hooks
|
|
19
|
-
* --json Machine-readable JSON
|
|
20
|
-
* (default) Rich terminal output
|
|
21
|
-
*/
|
|
22
|
-
import chalk from 'chalk';
|
|
23
|
-
import WebSocket from 'ws';
|
|
24
|
-
import { checkAnalysisReady, fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
|
|
25
|
-
import { loadToken } from '../utils/auth.js';
|
|
26
|
-
import { loadPendingSubmit, updatePendingSubmit, clearPendingSubmit } from '../utils/pending-state.js';
|
|
27
|
-
import { parsePrRef } from '../utils/pr-ref.js';
|
|
28
|
-
import { toPublicVerdict } from '../states.js';
|
|
29
|
-
import { withSchema } from '../schema.js';
|
|
30
|
-
import { fetchGitHubJson } from '../utils/haystack-api.js';
|
|
31
|
-
import { githubRestBaseFor } from '../utils/github-api.js';
|
|
32
|
-
// ============================================================================
|
|
33
|
-
// Output formatting
|
|
34
|
-
// ============================================================================
|
|
35
|
-
/**
|
|
36
|
-
* True if the finding is one the auto-fixer is already handling (and thus
|
|
37
|
-
* shouldn't be re-fixed). Correlation is by exact finding id — the same stable id
|
|
38
|
-
* analysis assigns and the fixer echoes back in its manifest. We never compare
|
|
39
|
-
* summary/category text.
|
|
40
|
-
*/
|
|
41
|
-
function isAutoFixing(finding, manifest) {
|
|
42
|
-
if (!finding.id)
|
|
43
|
-
return false;
|
|
44
|
-
const items = manifest?.fixedItems ?? [];
|
|
45
|
-
return items.some((item) => item.id === finding.id);
|
|
46
|
-
}
|
|
47
|
-
function printRichOutput(pr, synthesis, manifest) {
|
|
48
|
-
const reviewUrl = `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`;
|
|
49
|
-
console.log(`\n ${chalk.bold(`Haystack Triage: ${pr.owner}/${pr.repo}#${pr.prNumber}`)}\n`);
|
|
50
|
-
// Alpha output stays intentionally small. Findings remain fully visible below.
|
|
51
|
-
if (manifest && (manifest.fixedItems.length > 0 || manifest.leftForReview.length > 0)) {
|
|
52
|
-
const fixed = manifest.fixedItems.length;
|
|
53
|
-
const remaining = manifest.leftForReview.length;
|
|
54
|
-
console.log(chalk.dim(` Fixer: completed · fixed ${fixed}; ${remaining} remain for review.\n`));
|
|
55
|
-
}
|
|
56
|
-
// Every customer-facing finding stays visible regardless of fixer state.
|
|
57
|
-
const findings = synthesis.synthesisDisplay || [];
|
|
58
|
-
if (findings.length > 0) {
|
|
59
|
-
console.log(chalk.bold(' Findings to address:\n'));
|
|
60
|
-
for (let i = 0; i < findings.length; i++) {
|
|
61
|
-
const finding = findings[i];
|
|
62
|
-
const sourceTag = finding.source ? ` (${finding.source})` : '';
|
|
63
|
-
console.log(` ${i + 1}. [${finding.category}]${sourceTag} ${finding.summary}`);
|
|
64
|
-
if (finding.detail) {
|
|
65
|
-
for (const line of finding.detail.split('\n')) {
|
|
66
|
-
console.log(` ${line}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (finding.agentFixPrompt) {
|
|
70
|
-
const fixLines = finding.agentFixPrompt.split('\n');
|
|
71
|
-
console.log(` ${chalk.dim('Fix:')} ${fixLines[0]}`);
|
|
72
|
-
for (const line of fixLines.slice(1)) {
|
|
73
|
-
console.log(` ${line}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
console.log('');
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
console.log(chalk.dim(' No findings.\n'));
|
|
81
|
-
}
|
|
82
|
-
console.log(` ${chalk.dim(reviewUrl)}\n`);
|
|
83
|
-
}
|
|
84
|
-
export function buildJsonOutput(pr, headSha, synthesis, manifest) {
|
|
85
|
-
const payload = {
|
|
86
|
-
ref: `${pr.owner}/${pr.repo}#${pr.prNumber}`,
|
|
87
|
-
head_sha: headSha,
|
|
88
|
-
status: 'complete',
|
|
89
|
-
verdict: toPublicVerdict(synthesis.analysisVerdict, synthesis.haystackRating),
|
|
90
|
-
rating: synthesis.haystackRating,
|
|
91
|
-
human_review_reasons: synthesis.humanReviewReasons ?? [],
|
|
92
|
-
findings: (synthesis.synthesisDisplay || []).map((f) => ({
|
|
93
|
-
id: f.id ?? null,
|
|
94
|
-
category: f.category,
|
|
95
|
-
summary: f.summary,
|
|
96
|
-
detail: f.detail,
|
|
97
|
-
suggested_fix: f.agentFixPrompt ?? null,
|
|
98
|
-
source: f.source ?? null,
|
|
99
|
-
location: f.filePath || f.startLine != null || f.endLine != null
|
|
100
|
-
? {
|
|
101
|
-
path: f.filePath ?? null,
|
|
102
|
-
start_line: f.startLine ?? null,
|
|
103
|
-
end_line: f.endLine ?? null,
|
|
104
|
-
}
|
|
105
|
-
: null,
|
|
106
|
-
severity: f.severity ?? null,
|
|
107
|
-
})),
|
|
108
|
-
};
|
|
109
|
-
if (manifest) {
|
|
110
|
-
const fixed = manifest.fixedItems.length;
|
|
111
|
-
const remaining = manifest.leftForReview.length;
|
|
112
|
-
payload.fixer = {
|
|
113
|
-
status: 'completed',
|
|
114
|
-
result_sha: manifest.resultSha ?? headSha,
|
|
115
|
-
summary: `Fixed ${fixed} finding${fixed === 1 ? '' : 's'}; ${remaining} remain${remaining === 1 ? 's' : ''} for review.`,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
return withSchema('triage', payload);
|
|
119
|
-
}
|
|
120
|
-
export function buildJsonState(pr, status, message) {
|
|
121
|
-
const payload = {
|
|
122
|
-
ref: `${pr.owner}/${pr.repo}#${pr.prNumber}`,
|
|
123
|
-
head_sha: null,
|
|
124
|
-
status,
|
|
125
|
-
verdict: null,
|
|
126
|
-
rating: null,
|
|
127
|
-
human_review_reasons: [],
|
|
128
|
-
findings: [],
|
|
129
|
-
};
|
|
130
|
-
if (message)
|
|
131
|
-
payload.message = message;
|
|
132
|
-
return withSchema('triage', payload);
|
|
133
|
-
}
|
|
134
|
-
export async function fetchTriageHeadSha(pr, token) {
|
|
135
|
-
if (!token)
|
|
136
|
-
return null;
|
|
137
|
-
const meta = await fetchGitHubJson(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, token);
|
|
138
|
-
const headSha = meta.head?.sha;
|
|
139
|
-
if (!headSha)
|
|
140
|
-
throw new Error('GitHub PR metadata response is missing head.sha');
|
|
141
|
-
return headSha;
|
|
142
|
-
}
|
|
143
|
-
function printJsonState(pr, status, message) {
|
|
144
|
-
console.log(JSON.stringify(buildJsonState(pr, status, message), null, 2));
|
|
145
|
-
}
|
|
146
|
-
function ratingStars(rating) {
|
|
147
|
-
const filled = '\u2605'; // ★
|
|
148
|
-
const empty = '\u2606'; // ☆
|
|
149
|
-
return filled.repeat(rating) + empty.repeat(5 - rating);
|
|
150
|
-
}
|
|
151
|
-
function printHookOutput(pr, synthesis, manifest, status) {
|
|
152
|
-
const label = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
|
|
153
|
-
const reviewUrl = `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`;
|
|
154
|
-
if (status === 'pending') {
|
|
155
|
-
console.log(`[Haystack] ${label}: Analysis in progress...`);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (status === 'failed') {
|
|
159
|
-
console.log(`[Haystack] ${label}: Analysis failed`);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (status === 'stale') {
|
|
163
|
-
console.log(`[Haystack] ${label}: Stale (submitted > 2h ago)`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (!synthesis) {
|
|
167
|
-
console.log(`[Haystack] ${label}: Analysis in progress...`);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const stars = ratingStars(synthesis.haystackRating);
|
|
171
|
-
const fixingCount = manifest?.fixedItems?.length ?? 0;
|
|
172
|
-
// Only count findings the auto-fixer is NOT handling \u2014 those are what need the user.
|
|
173
|
-
const actionable = (synthesis.synthesisDisplay || []).filter((f) => !isAutoFixing(f, manifest));
|
|
174
|
-
if (actionable.length === 0 && fixingCount === 0) {
|
|
175
|
-
console.log(`[Haystack] \u2705 ${label} ${stars}: Good to merge`);
|
|
176
|
-
}
|
|
177
|
-
else if (actionable.length === 0) {
|
|
178
|
-
// Everything is being auto-fixed \u2014 nothing for the user to do.
|
|
179
|
-
console.log(`[Haystack] \u2705 ${label} ${stars}: ${fixingCount} being auto-fixed, nothing for you to do`);
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
const parts = [`${actionable.length} finding(s)`];
|
|
183
|
-
if (fixingCount > 0)
|
|
184
|
-
parts.push(`${fixingCount} being auto-fixed`);
|
|
185
|
-
console.log(`[Haystack] \u26A0\uFE0F ${label} ${stars}: Needs your input (${parts.join(', ')})`);
|
|
186
|
-
console.log(` ${reviewUrl}`);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
// ============================================================================
|
|
190
|
-
// Waiting (event-driven with polling fallback)
|
|
191
|
-
// ============================================================================
|
|
192
|
-
const POLL_INTERVAL_MS = 5_000;
|
|
193
|
-
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
194
|
-
/** With a live event subscription, checks are event-driven; this slow cycle
|
|
195
|
-
* is only a safety net against a silently dead socket. */
|
|
196
|
-
const SUBSCRIBED_RECHECK_MS = 60_000;
|
|
197
|
-
const PR_EVENTS_WS_BASE = process.env.HAYSTACK_PR_EVENTS_WS_URL ?? 'wss://haystackeditor.com/api/pr-events';
|
|
198
|
-
/**
|
|
199
|
-
* Subscribe to the pr-events worker for this PR — the same real-time channel
|
|
200
|
-
* the web inbox uses. Any event (analysis_complete, commit_pushed, ...) calls
|
|
201
|
-
* `onEvent`, and the caller re-checks analysis state immediately instead of
|
|
202
|
-
* discovering it on the next poll tick. Returns null when a socket can't even
|
|
203
|
-
* be constructed; connection failures after that surface via isConnected().
|
|
204
|
-
*/
|
|
205
|
-
function subscribeToPrEvents(owner, repo, prNumber, token, onEvent, quiet) {
|
|
206
|
-
// Diagnostics are dim stderr lines, once per failure mode — polling is the
|
|
207
|
-
// designed fallback, but WHY live updates are off must stay visible (PR001).
|
|
208
|
-
// quiet (--hook) mode stays terse: the hook output is machine-parsed.
|
|
209
|
-
const diag = (msg) => {
|
|
210
|
-
if (!quiet)
|
|
211
|
-
process.stderr.write(`\n ${chalk.dim(`[live updates unavailable: ${msg}; using polling]`)}\n`);
|
|
212
|
-
};
|
|
213
|
-
try {
|
|
214
|
-
const ws = new WebSocket(`${PR_EVENTS_WS_BASE}/${owner}/${repo}/${prNumber}/subscribe`, {
|
|
215
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
216
|
-
});
|
|
217
|
-
let connected = false;
|
|
218
|
-
let reported = false;
|
|
219
|
-
ws.on('open', () => { connected = true; });
|
|
220
|
-
ws.on('message', () => onEvent());
|
|
221
|
-
ws.on('error', (err) => {
|
|
222
|
-
connected = false;
|
|
223
|
-
if (!reported) {
|
|
224
|
-
reported = true;
|
|
225
|
-
diag(err.message);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
ws.on('close', () => { connected = false; });
|
|
229
|
-
const keepalive = setInterval(() => {
|
|
230
|
-
// A failed send means the socket died between ticks; the 'error'/'close'
|
|
231
|
-
// handlers above report it and flip isConnected() — nothing extra to say.
|
|
232
|
-
try {
|
|
233
|
-
ws.send('ping');
|
|
234
|
-
}
|
|
235
|
-
catch { /* surfaced via error/close handlers */ }
|
|
236
|
-
}, 30_000);
|
|
237
|
-
keepalive.unref?.();
|
|
238
|
-
return {
|
|
239
|
-
isConnected: () => connected,
|
|
240
|
-
close: () => {
|
|
241
|
-
clearInterval(keepalive);
|
|
242
|
-
// Idempotent teardown, not a fallback: close() on an already-dead
|
|
243
|
-
// socket can throw in ws and there is genuinely nothing to handle.
|
|
244
|
-
try {
|
|
245
|
-
ws.close();
|
|
246
|
-
}
|
|
247
|
-
catch { /* already closed */ }
|
|
248
|
-
},
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
252
|
-
diag(err instanceof Error ? err.message : String(err));
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
async function waitForAnalysis(owner, repo, prNumber, timeoutMs, token, quiet) {
|
|
257
|
-
const deadline = Date.now() + timeoutMs;
|
|
258
|
-
let consecutiveErrors = 0;
|
|
259
|
-
// Event-driven wake: a pr-events message resolves the current sleep so the
|
|
260
|
-
// re-check happens the moment analysis completes, not on the next tick.
|
|
261
|
-
let wake = null;
|
|
262
|
-
const subscription = token
|
|
263
|
-
? subscribeToPrEvents(owner, repo, prNumber, token, () => wake?.(), quiet)
|
|
264
|
-
: null;
|
|
265
|
-
try {
|
|
266
|
-
while (Date.now() < deadline) {
|
|
267
|
-
const result = await checkAnalysisReady(owner, repo, prNumber, token);
|
|
268
|
-
if (result.status === 'ready')
|
|
269
|
-
return 'ready';
|
|
270
|
-
if (result.status === 'error') {
|
|
271
|
-
consecutiveErrors++;
|
|
272
|
-
if (consecutiveErrors >= 5)
|
|
273
|
-
return 'error';
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
consecutiveErrors = 0;
|
|
277
|
-
}
|
|
278
|
-
const live = subscription?.isConnected() ?? false;
|
|
279
|
-
if (!quiet) {
|
|
280
|
-
const mode = live ? 'Waiting for analysis (live)...' : 'Waiting for analysis to complete...';
|
|
281
|
-
process.stderr.write(`\r ${chalk.dim(mode)}${' '.repeat(5)}`);
|
|
282
|
-
}
|
|
283
|
-
const interval = Math.min(live ? SUBSCRIBED_RECHECK_MS : POLL_INTERVAL_MS, Math.max(deadline - Date.now(), 0));
|
|
284
|
-
await new Promise((resolve) => {
|
|
285
|
-
const timer = setTimeout(() => { wake = null; resolve(); }, interval);
|
|
286
|
-
timer.unref?.();
|
|
287
|
-
wake = () => { clearTimeout(timer); wake = null; resolve(); };
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
return 'pending';
|
|
291
|
-
}
|
|
292
|
-
finally {
|
|
293
|
-
subscription?.close();
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
// ============================================================================
|
|
297
|
-
// PR resolution
|
|
298
|
-
// ============================================================================
|
|
299
|
-
/**
|
|
300
|
-
* Resolve a PR from an explicit identifier or from pending-submit state.
|
|
301
|
-
* Returns null (and handles output) if no PR can be resolved.
|
|
302
|
-
*/
|
|
303
|
-
function resolvePR(identifier, options) {
|
|
304
|
-
if (identifier) {
|
|
305
|
-
try {
|
|
306
|
-
return parsePrRef(identifier, 'triage');
|
|
307
|
-
}
|
|
308
|
-
catch (err) {
|
|
309
|
-
console.error(chalk.red(`\n${err.message}\n`));
|
|
310
|
-
process.exit(1);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
// No arg — read from pending state
|
|
314
|
-
const pending = loadPendingSubmit();
|
|
315
|
-
if (!pending) {
|
|
316
|
-
if (options.json) {
|
|
317
|
-
// Versioned error envelope (the old `{"status":"none"}` matched no
|
|
318
|
-
// schema); the caller asked for a triage and there is nothing to triage.
|
|
319
|
-
console.log(JSON.stringify(withSchema('error', {
|
|
320
|
-
status: 'error',
|
|
321
|
-
error: 'No pending submit to triage. Pass a PR identifier: haystack triage owner/repo#123 --json',
|
|
322
|
-
}), null, 2));
|
|
323
|
-
process.exitCode = 1;
|
|
324
|
-
}
|
|
325
|
-
else if (!options.hook) {
|
|
326
|
-
console.log(chalk.dim('No pending submits. Pass a PR number to triage a specific PR.\n'));
|
|
327
|
-
}
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
330
|
-
return { owner: pending.owner, repo: pending.repo, prNumber: pending.prNumber };
|
|
331
|
-
}
|
|
332
|
-
// ============================================================================
|
|
333
|
-
// Main command
|
|
334
|
-
// ============================================================================
|
|
335
|
-
export async function triageCommand(identifier, options) {
|
|
336
|
-
// Handle --clear
|
|
337
|
-
if (options.clear) {
|
|
338
|
-
clearPendingSubmit();
|
|
339
|
-
if (options.json) {
|
|
340
|
-
// stdout stays pure JSON in --json mode; use the shared action envelope.
|
|
341
|
-
console.log(JSON.stringify(withSchema('action', {
|
|
342
|
-
action: 'clear_pending',
|
|
343
|
-
status: 'ok',
|
|
344
|
-
ref: null,
|
|
345
|
-
detail: 'Pending submit state cleared.',
|
|
346
|
-
}), null, 2));
|
|
347
|
-
}
|
|
348
|
-
else if (!options.hook) {
|
|
349
|
-
console.log(chalk.dim('Pending submit state cleared.'));
|
|
350
|
-
}
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
// Resolve PR identifier — explicit arg or fall back to pending-submit.json
|
|
354
|
-
const pr = resolvePR(identifier, options);
|
|
355
|
-
if (!pr)
|
|
356
|
-
return;
|
|
357
|
-
const isQuiet = options.hook || options.json;
|
|
358
|
-
// Load GitHub token for private repo auth
|
|
359
|
-
let token;
|
|
360
|
-
try {
|
|
361
|
-
token = await loadToken({ owner: pr.owner, repo: pr.repo }) || undefined;
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
if (!isQuiet) {
|
|
365
|
-
console.log(chalk.yellow(`\n⚠ Could not load Haystack credentials: ${err.message}`));
|
|
366
|
-
console.log(chalk.dim(' Continuing without GitHub auth. Private repos may require `haystack login`.\n'));
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (!isQuiet) {
|
|
370
|
-
console.log(chalk.dim(`\nFetching analysis for ${pr.owner}/${pr.repo}#${pr.prNumber}...`));
|
|
371
|
-
}
|
|
372
|
-
// Check if analysis is ready — resolve to a terminal state before fetching
|
|
373
|
-
let analysisReady = false;
|
|
374
|
-
const readyResult = await checkAnalysisReady(pr.owner, pr.repo, pr.prNumber, token);
|
|
375
|
-
if (readyResult.status === 'ready') {
|
|
376
|
-
analysisReady = true;
|
|
377
|
-
}
|
|
378
|
-
else if (readyResult.status === 'pending') {
|
|
379
|
-
// Hook mode: short 5s poll. --no-wait: skip. Default: full poll.
|
|
380
|
-
if (options.hook) {
|
|
381
|
-
const hookResult = await waitForAnalysis(pr.owner, pr.repo, pr.prNumber, 5_000, token, true);
|
|
382
|
-
if (hookResult === 'ready') {
|
|
383
|
-
analysisReady = true;
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
updatePendingSubmitIfExists(pr);
|
|
387
|
-
printHookOutput(pr, null, null, 'pending');
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
else if (options.wait === false) {
|
|
392
|
-
if (options.json) {
|
|
393
|
-
printJsonState(pr, 'pending');
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
console.log(chalk.yellow('\n Analysis is still in progress.'));
|
|
397
|
-
console.log(chalk.dim(' Re-run without --no-wait to poll until complete.\n'));
|
|
398
|
-
}
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
else {
|
|
402
|
-
// Full poll
|
|
403
|
-
if (!options.json) {
|
|
404
|
-
console.log(chalk.dim(' Analysis in progress, waiting...'));
|
|
405
|
-
}
|
|
406
|
-
const waitResult = await waitForAnalysis(pr.owner, pr.repo, pr.prNumber, POLL_TIMEOUT_MS, token, options.json);
|
|
407
|
-
if (!options.json) {
|
|
408
|
-
process.stderr.write('\r' + ' '.repeat(60) + '\r');
|
|
409
|
-
}
|
|
410
|
-
if (waitResult === 'ready') {
|
|
411
|
-
analysisReady = true;
|
|
412
|
-
}
|
|
413
|
-
else if (waitResult === 'pending') {
|
|
414
|
-
if (options.json) {
|
|
415
|
-
printJsonState(pr, 'pending', 'Timed out waiting for analysis');
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
console.log(chalk.yellow('\n Timed out waiting for analysis.'));
|
|
419
|
-
console.log(chalk.dim(' Try again later: ') + chalk.cyan(`haystack triage ${pr.owner}/${pr.repo}#${pr.prNumber}\n`));
|
|
420
|
-
}
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
else {
|
|
424
|
-
// waitResult === 'error'
|
|
425
|
-
if (options.json) {
|
|
426
|
-
printJsonState(pr, 'failed', 'Analysis check failed');
|
|
427
|
-
}
|
|
428
|
-
else {
|
|
429
|
-
console.error(chalk.red('\n Analysis check failed.\n'));
|
|
430
|
-
}
|
|
431
|
-
process.exit(1);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
else {
|
|
436
|
-
// readyResult.status === 'error'
|
|
437
|
-
if (options.hook) {
|
|
438
|
-
printHookOutput(pr, null, null, 'failed');
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
if (options.json) {
|
|
442
|
-
printJsonState(pr, 'failed', readyResult.message);
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
console.error(chalk.red(`\n Analysis check failed: ${readyResult.message}\n`));
|
|
446
|
-
}
|
|
447
|
-
process.exit(1);
|
|
448
|
-
}
|
|
449
|
-
if (!analysisReady)
|
|
450
|
-
return;
|
|
451
|
-
// Fetch the full rating synthesis and auto-fix manifest in parallel.
|
|
452
|
-
// Auth failures throw a classified error — report it as failed (with the
|
|
453
|
-
// remediation) rather than the old behavior of "not available yet, retry".
|
|
454
|
-
let synthesis;
|
|
455
|
-
let autoFixManifest;
|
|
456
|
-
let headSha;
|
|
457
|
-
try {
|
|
458
|
-
[synthesis, autoFixManifest, headSha] = await Promise.all([
|
|
459
|
-
fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
|
|
460
|
-
fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
|
|
461
|
-
fetchTriageHeadSha(pr, token),
|
|
462
|
-
]);
|
|
463
|
-
}
|
|
464
|
-
catch (err) {
|
|
465
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
466
|
-
if (options.hook) {
|
|
467
|
-
// Session-start hooks must never fail the session — match the
|
|
468
|
-
// status-check error path above: print the failed line, exit 0.
|
|
469
|
-
printHookOutput(pr, null, null, 'failed');
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (options.json) {
|
|
473
|
-
printJsonState(pr, 'failed', message);
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
console.error(chalk.red(`\n ${message}\n`));
|
|
477
|
-
}
|
|
478
|
-
process.exit(1);
|
|
479
|
-
}
|
|
480
|
-
// Update pending state if this PR matches the pending submit
|
|
481
|
-
updatePendingSubmitIfExists(pr, synthesis ? 'completed' : undefined);
|
|
482
|
-
if (!synthesis) {
|
|
483
|
-
if (options.hook) {
|
|
484
|
-
printHookOutput(pr, null, null, 'pending');
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
if (options.json) {
|
|
488
|
-
printJsonState(pr, 'unavailable', 'Rating synthesis not available');
|
|
489
|
-
}
|
|
490
|
-
else {
|
|
491
|
-
console.error(chalk.yellow('\n Analysis completed but rating synthesis is not available yet.'));
|
|
492
|
-
console.log(chalk.dim(' Try again in a moment, or view the full review:'));
|
|
493
|
-
console.log(chalk.cyan(` https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}\n`));
|
|
494
|
-
}
|
|
495
|
-
process.exit(1);
|
|
496
|
-
}
|
|
497
|
-
// Output
|
|
498
|
-
if (options.hook) {
|
|
499
|
-
printHookOutput(pr, synthesis, autoFixManifest);
|
|
500
|
-
}
|
|
501
|
-
else if (options.json) {
|
|
502
|
-
console.log(JSON.stringify(buildJsonOutput(pr, headSha, synthesis, autoFixManifest), null, 2));
|
|
503
|
-
}
|
|
504
|
-
else {
|
|
505
|
-
printRichOutput(pr, synthesis, autoFixManifest);
|
|
506
|
-
}
|
|
507
|
-
// Record findings-seen marker (fire-and-forget — failure is non-fatal)
|
|
508
|
-
if (synthesis.synthesisDisplay && synthesis.synthesisDisplay.length > 0 && token) {
|
|
509
|
-
const findingKeys = synthesis.synthesisDisplay.map((f) => `${f.category}|${f.summary}`);
|
|
510
|
-
recordFindingsSeen(pr, token, findingKeys).catch((err) => {
|
|
511
|
-
// Non-blocking but logged — don't swallow silently
|
|
512
|
-
if (process.env.DEBUG) {
|
|
513
|
-
console.error(chalk.dim(` [debug] findings-seen write failed: ${err}`));
|
|
514
|
-
}
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
/** Pure read path for MCP: returns a public payload without writing stdout or exiting. */
|
|
519
|
-
export async function fetchTriagePayload(identifier) {
|
|
520
|
-
const pr = parsePrRef(identifier, 'triage');
|
|
521
|
-
const token = await loadToken({ owner: pr.owner, repo: pr.repo }) || undefined;
|
|
522
|
-
const ready = await checkAnalysisReady(pr.owner, pr.repo, pr.prNumber, token);
|
|
523
|
-
if (ready.status === 'pending')
|
|
524
|
-
return buildJsonState(pr, 'pending');
|
|
525
|
-
if (ready.status === 'error')
|
|
526
|
-
return buildJsonState(pr, 'failed', ready.message);
|
|
527
|
-
const [synthesis, manifest, headSha] = await Promise.all([
|
|
528
|
-
fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
|
|
529
|
-
fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
|
|
530
|
-
fetchTriageHeadSha(pr, token),
|
|
531
|
-
]);
|
|
532
|
-
if (!synthesis)
|
|
533
|
-
return buildJsonState(pr, 'unavailable', 'Rating synthesis not available');
|
|
534
|
-
return buildJsonOutput(pr, headSha, synthesis, manifest);
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Update the pending-submit state if the given PR matches the pending one.
|
|
538
|
-
* Best-effort — failure is silently ignored.
|
|
539
|
-
*/
|
|
540
|
-
function updatePendingSubmitIfExists(pr, status) {
|
|
541
|
-
try {
|
|
542
|
-
const pending = loadPendingSubmit();
|
|
543
|
-
if (!pending)
|
|
544
|
-
return;
|
|
545
|
-
if (pending.owner !== pr.owner || pending.repo !== pr.repo || pending.prNumber !== pr.prNumber)
|
|
546
|
-
return;
|
|
547
|
-
if (pending.status !== 'polling')
|
|
548
|
-
return;
|
|
549
|
-
if (status === 'completed') {
|
|
550
|
-
updatePendingSubmit({
|
|
551
|
-
status: 'completed',
|
|
552
|
-
resolvedAt: new Date().toISOString(),
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
catch {
|
|
557
|
-
// Non-fatal
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
/**
|
|
561
|
-
* Record that the user has seen analysis findings for this PR.
|
|
562
|
-
* Fire-and-forget: failure is silently ignored.
|
|
563
|
-
*/
|
|
564
|
-
async function recordFindingsSeen(pr, token, findingKeys) {
|
|
565
|
-
// Get PR head SHA
|
|
566
|
-
const ghResp = await fetch(`${githubRestBaseFor(token)}/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, {
|
|
567
|
-
headers: {
|
|
568
|
-
Authorization: `Bearer ${token}`,
|
|
569
|
-
Accept: 'application/vnd.github.v3+json',
|
|
570
|
-
'User-Agent': 'Haystack-CLI',
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
if (!ghResp.ok) {
|
|
574
|
-
throw new Error(`GitHub API returned ${ghResp.status}`);
|
|
575
|
-
}
|
|
576
|
-
const ghData = await ghResp.json();
|
|
577
|
-
const sha = ghData?.head?.sha;
|
|
578
|
-
if (typeof sha !== 'string' || !sha) {
|
|
579
|
-
throw new Error('Unexpected GitHub response: missing head.sha');
|
|
580
|
-
}
|
|
581
|
-
const resp = await fetch('https://haystackeditor.com/api/findings-seen', {
|
|
582
|
-
method: 'POST',
|
|
583
|
-
headers: {
|
|
584
|
-
Authorization: `Bearer ${token}`,
|
|
585
|
-
'Content-Type': 'application/json',
|
|
586
|
-
'User-Agent': 'Haystack-CLI',
|
|
587
|
-
},
|
|
588
|
-
body: JSON.stringify({
|
|
589
|
-
repoFullName: `${pr.owner}/${pr.repo}`,
|
|
590
|
-
prNumber: pr.prNumber,
|
|
591
|
-
commitSha: sha,
|
|
592
|
-
findingKeys,
|
|
593
|
-
}),
|
|
594
|
-
});
|
|
595
|
-
if (!resp.ok) {
|
|
596
|
-
throw new Error(`findings-seen API returned ${resp.status}`);
|
|
597
|
-
}
|
|
598
|
-
}
|