@haystackeditor/cli 0.15.5 → 0.15.7
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/dist/commands/setup.js +58 -242
- package/dist/commands/triage.js +59 -12
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -56,6 +56,10 @@ function buildAnswerMap(options) {
|
|
|
56
56
|
answers.upgrade_entire = true;
|
|
57
57
|
if (!('install_hooks' in answers))
|
|
58
58
|
answers.install_hooks = true;
|
|
59
|
+
// Don't auto-launch a browser in an unattended --yes run; the feed URL is
|
|
60
|
+
// still printed and returned in the result for the caller to open.
|
|
61
|
+
if (!('open_feed' in answers))
|
|
62
|
+
answers.open_feed = false;
|
|
59
63
|
}
|
|
60
64
|
return answers;
|
|
61
65
|
}
|
|
@@ -125,7 +129,7 @@ const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
|
|
|
125
129
|
const AUTO_MERGE_LABEL = 'haystack:auto-merge';
|
|
126
130
|
const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
|
|
127
131
|
const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
|
|
128
|
-
const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard
|
|
132
|
+
const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard to configure Haystack for this repo.\n\n' +
|
|
129
133
|
'It adds the Haystack onboarding config:\n' +
|
|
130
134
|
'- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
|
|
131
135
|
'- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
|
|
@@ -146,47 +150,6 @@ const BOOTSTRAP_PR_OUTRO_MANUAL = 'Auto-merge is off for this repo, so this PR i
|
|
|
146
150
|
function encodeBranchPath(branch) {
|
|
147
151
|
return branch.split('/').map(encodeURIComponent).join('/');
|
|
148
152
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Is the given branch protected such that a direct commit can't land —
|
|
151
|
-
* either by legacy branch protection OR a repository ruleset?
|
|
152
|
-
*
|
|
153
|
-
* Decided from GitHub's *structured* signals, not error-message text:
|
|
154
|
-
* - `GET /repos/{o}/{r}/branches/{b}` → `.protected` (legacy protection)
|
|
155
|
-
* - `GET /repos/{o}/{r}/rules/branches/{b}` → a `pull_request` rule means
|
|
156
|
-
* "changes must go through a PR" (rulesets)
|
|
157
|
-
*
|
|
158
|
-
* Called on the write-failure path: a 409/422 from the direct commit tells
|
|
159
|
-
* us *that* something rejected the write; this tells us *whether* it was
|
|
160
|
-
* protection (→ open a bootstrap PR) vs. a genuine bad request (→ surface
|
|
161
|
-
* the error). If the probe itself can't be completed we throw — guessing
|
|
162
|
-
* from message wording is exactly the brittleness we're avoiding here.
|
|
163
|
-
*/
|
|
164
|
-
async function isBranchProtected(owner, repo, branch, token) {
|
|
165
|
-
const headers = ghHeaders(token);
|
|
166
|
-
const enc = encodeBranchPath(branch);
|
|
167
|
-
// Legacy branch protection — the `protected` boolean is authoritative.
|
|
168
|
-
const branchRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/branches/${enc}`, { headers });
|
|
169
|
-
if (branchRes.ok) {
|
|
170
|
-
const data = (await branchRes.json());
|
|
171
|
-
if (data.protected === true)
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
else if (branchRes.status !== 404) {
|
|
175
|
-
throw new Error(`Couldn't check branch protection: ${branchRes.status} — ${await branchRes.text()}`);
|
|
176
|
-
}
|
|
177
|
-
// Repository rulesets — `rules/branches` collapses every active rule on
|
|
178
|
-
// the branch into one list. A `pull_request` rule = "must merge via PR".
|
|
179
|
-
const rulesRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
|
|
180
|
-
if (rulesRes.ok) {
|
|
181
|
-
const rules = (await rulesRes.json());
|
|
182
|
-
if (rules.some((r) => r.type === 'pull_request'))
|
|
183
|
-
return true;
|
|
184
|
-
}
|
|
185
|
-
else if (rulesRes.status !== 404) {
|
|
186
|
-
throw new Error(`Couldn't check repository rulesets: ${rulesRes.status} — ${await rulesRes.text()}`);
|
|
187
|
-
}
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
153
|
function ghHeaders(token) {
|
|
191
154
|
return {
|
|
192
155
|
Authorization: `Bearer ${token}`,
|
|
@@ -381,56 +344,6 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
|
|
|
381
344
|
`Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
|
|
382
345
|
`Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
|
|
383
346
|
}
|
|
384
|
-
/** Wraps a contents-API write failure with its HTTP status so the caller can
|
|
385
|
-
* distinguish a protection rejection (409/422 → PR fallback) from a genuine
|
|
386
|
-
* error (→ surface it). */
|
|
387
|
-
class GitHubWriteError extends Error {
|
|
388
|
-
status;
|
|
389
|
-
body;
|
|
390
|
-
constructor(status, body) {
|
|
391
|
-
super(`GitHub write failed: ${status}`);
|
|
392
|
-
this.status = status;
|
|
393
|
-
this.body = body;
|
|
394
|
-
this.name = 'GitHubWriteError';
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
/** Read the blob SHA of an existing file (needed to update it). Non-OK (incl.
|
|
398
|
-
* 404 = file doesn't exist yet) returns null; the subsequent PUT then creates
|
|
399
|
-
* the file, and surfaces a real error if the write itself fails. */
|
|
400
|
-
async function fetchFileSha(owner, repo, path, token) {
|
|
401
|
-
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, { headers: ghHeaders(token) });
|
|
402
|
-
if (!res.ok) {
|
|
403
|
-
if (res.status !== 404) {
|
|
404
|
-
trackError('haystack_setup_fetch_file_sha_failed', {
|
|
405
|
-
owner,
|
|
406
|
-
repo,
|
|
407
|
-
path,
|
|
408
|
-
status: res.status,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
const data = (await res.json());
|
|
414
|
-
return data.sha ?? null;
|
|
415
|
-
}
|
|
416
|
-
/** PUT a single file to the default branch via the contents API. Throws
|
|
417
|
-
* GitHubWriteError on any non-OK response so the caller can branch on status. */
|
|
418
|
-
async function putFile(owner, repo, path, content, message, sha, token) {
|
|
419
|
-
const body = {
|
|
420
|
-
message,
|
|
421
|
-
content: Buffer.from(content).toString('base64'),
|
|
422
|
-
};
|
|
423
|
-
if (sha)
|
|
424
|
-
body.sha = sha;
|
|
425
|
-
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, {
|
|
426
|
-
method: 'PUT',
|
|
427
|
-
headers: ghHeaders(token),
|
|
428
|
-
body: JSON.stringify(body),
|
|
429
|
-
});
|
|
430
|
-
if (!res.ok) {
|
|
431
|
-
throw new GitHubWriteError(res.status, await res.text().catch(() => ''));
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
347
|
/**
|
|
435
348
|
* Protected-branch fallback: build ONE commit containing ALL the onboarding
|
|
436
349
|
* files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
|
|
@@ -495,50 +408,17 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
|
|
|
495
408
|
return { prUrl: pr.html_url, entireSettingsBundled };
|
|
496
409
|
}
|
|
497
410
|
/**
|
|
498
|
-
* Write the onboarding config files to a repo
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
|
|
505
|
-
* thread the branch into step 7), `{}` on clean direct commits.
|
|
411
|
+
* Write the onboarding config files to a repo by ALWAYS opening a PR
|
|
412
|
+
* (branch → one commit → PR) — never a direct commit to the default branch.
|
|
413
|
+
* Matches the web wizard (#1936): a silent push to someone's default branch is
|
|
414
|
+
* jarring and hard to undo, so onboarding always goes through a "Configure
|
|
415
|
+
* Haystack" PR. The PR is enrolled for auto-merge only when the user kept
|
|
416
|
+
* auto-merge on; otherwise it waits for them to review and merge it.
|
|
506
417
|
*/
|
|
507
418
|
async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const shaByPath = new Map();
|
|
512
|
-
for (const f of files) {
|
|
513
|
-
shaByPath.set(f.path, await fetchFileSha(owner, repo, f.path, token));
|
|
514
|
-
}
|
|
515
|
-
let successfulWrites = 0;
|
|
516
|
-
try {
|
|
517
|
-
for (const f of files) {
|
|
518
|
-
await putFile(owner, repo, f.path, f.content, f.message, shaByPath.get(f.path) ?? null, token);
|
|
519
|
-
successfulWrites++;
|
|
520
|
-
}
|
|
521
|
-
return {};
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
// Only a 409/422 can be a protection rejection. Anything else (auth, 404,
|
|
525
|
-
// 5xx, network) is a genuine error — surface it.
|
|
526
|
-
const status = err instanceof GitHubWriteError ? err.status : 0;
|
|
527
|
-
if (status !== 409 && status !== 422)
|
|
528
|
-
throw err;
|
|
529
|
-
if (successfulWrites > 0) {
|
|
530
|
-
throw err;
|
|
531
|
-
}
|
|
532
|
-
// Probe GitHub's *structured* protection signals to decide: protection
|
|
533
|
-
// (→ PR fallback) vs. a genuine bad request / sha conflict (→ surface the
|
|
534
|
-
// error). This replaces brittle error-message substring matching.
|
|
535
|
-
const defaultBranch = await getDefaultBranch(owner, repo, token);
|
|
536
|
-
if (!(await isBranchProtected(owner, repo, defaultBranch, token))) {
|
|
537
|
-
throw err;
|
|
538
|
-
}
|
|
539
|
-
const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
|
|
540
|
-
return { bootstrapPR };
|
|
541
|
-
}
|
|
419
|
+
const defaultBranch = await getDefaultBranch(owner, repo, token);
|
|
420
|
+
const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
|
|
421
|
+
return { bootstrapPR };
|
|
542
422
|
}
|
|
543
423
|
const SCAN_POLL_INTERVAL_MS = 1500;
|
|
544
424
|
// A run still marked `running` but whose KV state hasn't been touched for this
|
|
@@ -864,8 +744,6 @@ async function stepSelectRepos(token) {
|
|
|
864
744
|
const PR_RULES_PATH = '.haystack/pr-rules.yml';
|
|
865
745
|
const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
|
|
866
746
|
const HAYSTACK_CONFIG_PATH = '.haystack.json';
|
|
867
|
-
const RULES_COMMIT_MSG = 'chore: configure Haystack rules via CLI setup wizard';
|
|
868
|
-
const POLICY_COMMIT_MSG = 'chore: add review policies via CLI setup wizard';
|
|
869
747
|
/**
|
|
870
748
|
* Scan each selected repo with one unified scan and collect the proposed
|
|
871
749
|
* rules / policies / instructions per repo. A failed scan for one repo records
|
|
@@ -1093,13 +971,13 @@ function buildOnboardingFiles(result, autoMerge) {
|
|
|
1093
971
|
const files = [];
|
|
1094
972
|
const prRules = buildPrRules(result.rules);
|
|
1095
973
|
if (prRules.length > 0) {
|
|
1096
|
-
files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules)
|
|
974
|
+
files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules) });
|
|
1097
975
|
}
|
|
1098
976
|
const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
|
|
1099
977
|
if (reviewPolicyMd) {
|
|
1100
|
-
files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd
|
|
978
|
+
files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd });
|
|
1101
979
|
}
|
|
1102
|
-
files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge)
|
|
980
|
+
files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
|
|
1103
981
|
return files;
|
|
1104
982
|
}
|
|
1105
983
|
async function stepConfirm(byRepo, token) {
|
|
@@ -1146,24 +1024,17 @@ async function stepConfirm(byRepo, token) {
|
|
|
1146
1024
|
const result = byRepo.get(repoFullName);
|
|
1147
1025
|
const files = buildOnboardingFiles(result, autoMerge);
|
|
1148
1026
|
try {
|
|
1149
|
-
ui.progress(`
|
|
1027
|
+
ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
|
|
1150
1028
|
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
|
|
1151
1029
|
ui.clearProgress();
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
: ' (default branch protected — opened a PR for you to review and merge)';
|
|
1161
|
-
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
|
|
1162
|
-
console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
|
|
1163
|
-
}
|
|
1164
|
-
else {
|
|
1165
|
-
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
1166
|
-
}
|
|
1030
|
+
// Onboarding always opens a PR (never a direct commit). With auto-merge on,
|
|
1031
|
+
// Haystack merges it once its labels are processed; with auto-merge off it
|
|
1032
|
+
// waits for the user. Step 7 ensures .entire/settings.json rode along in
|
|
1033
|
+
// the same commit.
|
|
1034
|
+
bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
|
|
1035
|
+
const note = autoMerge ? ' (opened a PR)' : ' (opened a PR for you to review and merge)';
|
|
1036
|
+
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
|
|
1037
|
+
console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
|
|
1167
1038
|
}
|
|
1168
1039
|
catch (err) {
|
|
1169
1040
|
ui.clearProgress();
|
|
@@ -1242,22 +1113,34 @@ async function runSetupFlow(options) {
|
|
|
1242
1113
|
ui.result({ status: 'cancelled' });
|
|
1243
1114
|
return;
|
|
1244
1115
|
}
|
|
1245
|
-
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
|
|
1246
|
-
//
|
|
1247
|
-
// .entire/settings.json
|
|
1248
|
-
// --skip-entire skips the whole step (binary install
|
|
1249
|
-
//
|
|
1116
|
+
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it which
|
|
1117
|
+
// repos got a Configure Haystack PR (all of them) so it can report that
|
|
1118
|
+
// .entire/settings.json rode along in that PR's commit.
|
|
1119
|
+
// --skip-entire skips the whole step (binary install + hooks) — not just the
|
|
1120
|
+
// install prompt.
|
|
1250
1121
|
if (options.skipEntire) {
|
|
1251
1122
|
console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
|
|
1252
1123
|
}
|
|
1253
1124
|
else {
|
|
1254
1125
|
await stepInstallEntire(selectedRepos, token, bootstrapPRs);
|
|
1255
1126
|
}
|
|
1127
|
+
// Close the loop: point the user to their Haystack feed (the inbox/card stack
|
|
1128
|
+
// where their PRs surface). Interactive users get offered a browser open; an
|
|
1129
|
+
// agent gets the URL in the `result` event to surface/open as it sees fit.
|
|
1130
|
+
const feedUrl = `${HAYSTACK_API}/inbox`;
|
|
1131
|
+
console.log(chalk.bold('\n Your Haystack feed:'));
|
|
1132
|
+
console.log(` ${chalk.cyan(feedUrl)}\n`);
|
|
1133
|
+
if (ui.interactive) {
|
|
1134
|
+
const openFeed = await ui.confirm({ id: 'open_feed', message: 'Open your Haystack feed now?', default: true });
|
|
1135
|
+
if (openFeed)
|
|
1136
|
+
tryOpenBrowser(feedUrl);
|
|
1137
|
+
}
|
|
1256
1138
|
// Final structured outcome for non-interactive/agent callers.
|
|
1257
1139
|
ui.result({
|
|
1258
1140
|
status: 'complete',
|
|
1259
1141
|
repos: selectedRepos,
|
|
1260
1142
|
pullRequests: [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl })),
|
|
1143
|
+
feedUrl,
|
|
1261
1144
|
});
|
|
1262
1145
|
}
|
|
1263
1146
|
// =============================================================================
|
|
@@ -1344,53 +1227,6 @@ async function installEntireBinary() {
|
|
|
1344
1227
|
return false;
|
|
1345
1228
|
}
|
|
1346
1229
|
}
|
|
1347
|
-
/**
|
|
1348
|
-
* Write .entire/settings.json directly to the default branch.
|
|
1349
|
-
*
|
|
1350
|
-
* This is the DIRECT path only. Repos whose default branch is protected
|
|
1351
|
-
* never reach here — step 6's openBootstrapConfigPR already bundled
|
|
1352
|
-
* .entire/settings.json into the bootstrap PR's single commit (on a ~ALL
|
|
1353
|
-
* ruleset you can't add it as a later commit, so it must ride along in the
|
|
1354
|
-
* branch-creation commit). stepInstallEntire skips this call for those
|
|
1355
|
-
* repos. So if we're here, step 6 proved the default branch accepts a
|
|
1356
|
-
* direct commit.
|
|
1357
|
-
*/
|
|
1358
|
-
async function configureEntireSettingsViaAPI(repoFullName, token) {
|
|
1359
|
-
const [owner, repo] = repoFullName.split('/');
|
|
1360
|
-
const result = await buildEntireSettingsContent(repoFullName, token);
|
|
1361
|
-
// null → file already exists unchanged; nothing to write.
|
|
1362
|
-
if (result === null)
|
|
1363
|
-
return;
|
|
1364
|
-
// buildEntireSettingsContent already fetched the file — reuse its
|
|
1365
|
-
// content + existingSha. A second GET here would (a) be redundant and
|
|
1366
|
-
// (b) on a transient non-404 failure fall back to existingSha=null,
|
|
1367
|
-
// turning the PUT into a create that GitHub rejects 422 — masking the
|
|
1368
|
-
// real read error. (Non-404 read failures already threw upstream.)
|
|
1369
|
-
const { content, existingSha } = result;
|
|
1370
|
-
const headers = ghHeaders(token);
|
|
1371
|
-
const body = {
|
|
1372
|
-
message: 'chore: configure Entire CLI settings',
|
|
1373
|
-
content: Buffer.from(content).toString('base64'),
|
|
1374
|
-
};
|
|
1375
|
-
if (existingSha)
|
|
1376
|
-
body.sha = existingSha;
|
|
1377
|
-
const putResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
|
|
1378
|
-
method: 'PUT',
|
|
1379
|
-
headers,
|
|
1380
|
-
body: JSON.stringify(body),
|
|
1381
|
-
});
|
|
1382
|
-
if (!putResp.ok) {
|
|
1383
|
-
const errText = await putResp.text().catch(() => '');
|
|
1384
|
-
const error = new Error(`GitHub API ${putResp.status}: ${errText}`);
|
|
1385
|
-
trackError('entire_settings_write_failed', {
|
|
1386
|
-
error_message: error.message,
|
|
1387
|
-
repo: repoFullName,
|
|
1388
|
-
status: putResp.status,
|
|
1389
|
-
had_existing: !!existingSha,
|
|
1390
|
-
});
|
|
1391
|
-
throw error;
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
1230
|
async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
1395
1231
|
console.log(chalk.bold('\n Step 7: Session tracking (Entire CLI)\n'));
|
|
1396
1232
|
const status = isEntireInstalled();
|
|
@@ -1429,48 +1265,28 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1429
1265
|
if (!userConsented) {
|
|
1430
1266
|
console.log(chalk.dim(' Skipping session tracking.\n'));
|
|
1431
1267
|
}
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
// step 6, which ALREADY bundled .entire/settings.json into that PR's
|
|
1436
|
-
// single commit (on a ~ALL ruleset it can't be added as a later commit).
|
|
1437
|
-
// So we skip those here and only direct-write the non-protected repos.
|
|
1268
|
+
// Onboarding always opens a PR, and .entire/settings.json rides along in that
|
|
1269
|
+
// PR's single commit (see openBootstrapConfigPR), so there's nothing to write
|
|
1270
|
+
// separately here — just report what landed.
|
|
1438
1271
|
if (userConsented) {
|
|
1439
|
-
let configured = 0;
|
|
1440
1272
|
let bundled = 0;
|
|
1441
1273
|
for (const repoFullName of selectedRepos) {
|
|
1442
1274
|
const bootstrapPR = bootstrapPRs.get(repoFullName);
|
|
1443
|
-
if (bootstrapPR) {
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in bootstrap PR)'));
|
|
1447
|
-
bundled++;
|
|
1448
|
-
}
|
|
1449
|
-
else {
|
|
1450
|
-
// The bootstrap PR shipped the config files but not
|
|
1451
|
-
// .entire/settings.json — its optional read failed transiently. Don't
|
|
1452
|
-
// count it as configured; the warning was already shown when the PR
|
|
1453
|
-
// was opened.
|
|
1454
|
-
console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
|
|
1455
|
-
chalk.dim(' — bootstrap PR has config files only; re-run setup to add session tracking'));
|
|
1456
|
-
}
|
|
1457
|
-
continue;
|
|
1275
|
+
if (bootstrapPR?.entireSettingsBundled) {
|
|
1276
|
+
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in PR)'));
|
|
1277
|
+
bundled++;
|
|
1458
1278
|
}
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
console.log(chalk.
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1466
|
-
catch (err) {
|
|
1467
|
-
ui.clearProgress();
|
|
1468
|
-
console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
|
|
1279
|
+
else {
|
|
1280
|
+
// The PR shipped the config files but not .entire/settings.json — its
|
|
1281
|
+
// optional read failed transiently. The warning was already shown when
|
|
1282
|
+
// the PR was opened.
|
|
1283
|
+
console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
|
|
1284
|
+
chalk.dim(' — PR has config files only; re-run setup to add session tracking'));
|
|
1469
1285
|
}
|
|
1470
1286
|
}
|
|
1471
|
-
if (
|
|
1472
|
-
console.log(chalk.green(`\n ✓ Session tracking configured on ${
|
|
1473
|
-
trackSetupEvent('entire_configured', { configured:
|
|
1287
|
+
if (bundled > 0) {
|
|
1288
|
+
console.log(chalk.green(`\n ✓ Session tracking configured on ${bundled} repo(s) (transcripts auto-compacted)\n`));
|
|
1289
|
+
trackSetupEvent('entire_configured', { configured: bundled, total: selectedRepos.length });
|
|
1474
1290
|
}
|
|
1475
1291
|
else {
|
|
1476
1292
|
console.log(chalk.yellow('\n ⚠ Could not configure session tracking on any repos.\n'));
|
package/dist/commands/triage.js
CHANGED
|
@@ -68,20 +68,54 @@ function parsePRIdentifier(identifier) {
|
|
|
68
68
|
// ============================================================================
|
|
69
69
|
// Output formatting
|
|
70
70
|
// ============================================================================
|
|
71
|
+
/**
|
|
72
|
+
* Decide whether a finding is one the auto-fixer is already handling (and thus
|
|
73
|
+
* shouldn't be re-fixed). The fixer re-derives — often paraphrases — its manifest
|
|
74
|
+
* summaries rather than copying them verbatim, so exact string matching misses
|
|
75
|
+
* them. This mirrors the normalized + fuzzy matching the backend uses in
|
|
76
|
+
* agent/cloudflare/src/auto-fix-post-synthesis.ts (normalizeText / categoriesCompatible).
|
|
77
|
+
*/
|
|
78
|
+
function normalizeText(value) {
|
|
79
|
+
return value.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
80
|
+
}
|
|
81
|
+
/** Categories match if equal, or if one's tokens are a subset of the other's. */
|
|
82
|
+
function categoriesCompatible(left, right) {
|
|
83
|
+
const a = normalizeText(left);
|
|
84
|
+
const b = normalizeText(right);
|
|
85
|
+
if (a === b)
|
|
86
|
+
return true;
|
|
87
|
+
const aTokens = a.split(/[^a-z0-9]+/).filter(Boolean);
|
|
88
|
+
const bTokens = b.split(/[^a-z0-9]+/).filter(Boolean);
|
|
89
|
+
const aSet = new Set(aTokens);
|
|
90
|
+
const bSet = new Set(bTokens);
|
|
91
|
+
return ((bTokens.length > 0 && bTokens.every((t) => aSet.has(t))) ||
|
|
92
|
+
(aTokens.length > 0 && aTokens.every((t) => bSet.has(t))));
|
|
93
|
+
}
|
|
94
|
+
/** True if the finding matches any item the auto-fixer is handling. */
|
|
95
|
+
function isAutoFixing(finding, manifest) {
|
|
96
|
+
const items = manifest?.fixedItems ?? [];
|
|
97
|
+
if (items.length === 0)
|
|
98
|
+
return false;
|
|
99
|
+
const summaryKey = normalizeText(finding.summary);
|
|
100
|
+
if (summaryKey.length === 0)
|
|
101
|
+
return false;
|
|
102
|
+
return items.some((item) => normalizeText(item.summary) === summaryKey &&
|
|
103
|
+
categoriesCompatible(finding.category, item.category));
|
|
104
|
+
}
|
|
71
105
|
function printRichOutput(pr, synthesis, manifest) {
|
|
72
106
|
const reviewUrl = `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`;
|
|
73
107
|
console.log(`\n ${chalk.bold(`Haystack Triage: ${pr.owner}/${pr.repo}#${pr.prNumber}`)}\n`);
|
|
74
108
|
// Auto-fixer status — what's being fixed vs what it skipped
|
|
75
109
|
if (manifest && (manifest.fixedItems.length > 0 || manifest.leftForReview.length > 0)) {
|
|
76
110
|
if (manifest.fixedItems.length > 0) {
|
|
77
|
-
console.log(chalk.bold(' Auto-fixer is handling:\n'));
|
|
111
|
+
console.log(chalk.bold(' Auto-fixer is handling these (leave them alone):\n'));
|
|
78
112
|
for (const item of manifest.fixedItems) {
|
|
79
113
|
console.log(` - [${item.category}] ${item.summary}`);
|
|
80
114
|
}
|
|
81
|
-
console.log('
|
|
115
|
+
console.log(` ${chalk.dim("Don't fix these. The auto-fixer is already on them and will push its own commit.")}\n`);
|
|
82
116
|
}
|
|
83
117
|
if (manifest.leftForReview.length > 0) {
|
|
84
|
-
console.log(chalk.bold(' Auto-fixer skipped:\n'));
|
|
118
|
+
console.log(chalk.bold(' Auto-fixer skipped these (they need you):\n'));
|
|
85
119
|
for (const item of manifest.leftForReview) {
|
|
86
120
|
console.log(` - [${item.category}] ${item.summary}`);
|
|
87
121
|
console.log(` ${chalk.dim(item.reason)}`);
|
|
@@ -89,10 +123,13 @@ function printRichOutput(pr, synthesis, manifest) {
|
|
|
89
123
|
console.log('');
|
|
90
124
|
}
|
|
91
125
|
}
|
|
92
|
-
// Findings — the actionable output for agents
|
|
93
|
-
|
|
126
|
+
// Findings — the actionable output for agents. Exclude anything the auto-fixer
|
|
127
|
+
// is already handling so the agent doesn't duplicate (and fight) its work.
|
|
128
|
+
const allFindings = synthesis.synthesisDisplay || [];
|
|
129
|
+
const findings = allFindings.filter((f) => !isAutoFixing(f, manifest));
|
|
130
|
+
const fixingCount = manifest?.fixedItems?.length ?? 0;
|
|
94
131
|
if (findings.length > 0) {
|
|
95
|
-
console.log(chalk.bold(' Findings:\n'));
|
|
132
|
+
console.log(chalk.bold(' Findings to address:\n'));
|
|
96
133
|
for (let i = 0; i < findings.length; i++) {
|
|
97
134
|
const finding = findings[i];
|
|
98
135
|
const sourceTag = finding.source ? ` (${finding.source})` : '';
|
|
@@ -112,7 +149,11 @@ function printRichOutput(pr, synthesis, manifest) {
|
|
|
112
149
|
console.log('');
|
|
113
150
|
}
|
|
114
151
|
}
|
|
115
|
-
else if (
|
|
152
|
+
else if (fixingCount > 0 && allFindings.length > 0) {
|
|
153
|
+
// Every finding is being auto-fixed — nothing left for the agent.
|
|
154
|
+
console.log(chalk.dim(' Nothing to address. Every finding is being auto-fixed (see above).\n'));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
116
157
|
console.log(chalk.dim(' No findings.\n'));
|
|
117
158
|
}
|
|
118
159
|
console.log(` ${chalk.dim(reviewUrl)}\n`);
|
|
@@ -123,6 +164,7 @@ function printJsonOutput(pr, synthesis, manifest) {
|
|
|
123
164
|
repo: pr.repo,
|
|
124
165
|
prNumber: pr.prNumber,
|
|
125
166
|
rating: synthesis.haystackRating,
|
|
167
|
+
// Issues the auto-fixer is already on — leave these alone, do not re-fix.
|
|
126
168
|
autoFixerHandling: manifest?.fixedItems ?? [],
|
|
127
169
|
autoFixerSkipped: manifest?.leftForReview ?? [],
|
|
128
170
|
findings: (synthesis.synthesisDisplay || []).map((f) => ({
|
|
@@ -131,6 +173,8 @@ function printJsonOutput(pr, synthesis, manifest) {
|
|
|
131
173
|
detail: f.detail,
|
|
132
174
|
agentFixPrompt: f.agentFixPrompt || null,
|
|
133
175
|
source: f.source || null,
|
|
176
|
+
// True when the auto-fixer is handling this finding — don't fix it yourself.
|
|
177
|
+
autoFixing: isAutoFixing(f, manifest),
|
|
134
178
|
})),
|
|
135
179
|
};
|
|
136
180
|
console.log(JSON.stringify(output, null, 2));
|
|
@@ -159,16 +203,19 @@ function printHookOutput(pr, synthesis, manifest, status) {
|
|
|
159
203
|
console.log(`[Haystack] ${label}: Analysis in progress...`);
|
|
160
204
|
return;
|
|
161
205
|
}
|
|
162
|
-
const findings = synthesis.synthesisDisplay || [];
|
|
163
206
|
const stars = ratingStars(synthesis.haystackRating);
|
|
164
207
|
const fixingCount = manifest?.fixedItems?.length ?? 0;
|
|
165
|
-
|
|
208
|
+
// Only count findings the auto-fixer is NOT handling \u2014 those are what need the user.
|
|
209
|
+
const actionable = (synthesis.synthesisDisplay || []).filter((f) => !isAutoFixing(f, manifest));
|
|
210
|
+
if (actionable.length === 0 && fixingCount === 0) {
|
|
166
211
|
console.log(`[Haystack] \u2705 ${label} ${stars}: Good to merge`);
|
|
167
212
|
}
|
|
213
|
+
else if (actionable.length === 0) {
|
|
214
|
+
// Everything is being auto-fixed \u2014 nothing for the user to do.
|
|
215
|
+
console.log(`[Haystack] \u2705 ${label} ${stars}: ${fixingCount} being auto-fixed, nothing for you to do`);
|
|
216
|
+
}
|
|
168
217
|
else {
|
|
169
|
-
const parts = [];
|
|
170
|
-
if (findings.length > 0)
|
|
171
|
-
parts.push(`${findings.length} finding(s)`);
|
|
218
|
+
const parts = [`${actionable.length} finding(s)`];
|
|
172
219
|
if (fixingCount > 0)
|
|
173
220
|
parts.push(`${fixingCount} being auto-fixed`);
|
|
174
221
|
console.log(`[Haystack] \u26A0\uFE0F ${label} ${stars}: Needs your input (${parts.join(', ')})`);
|