@haystackeditor/cli 0.14.1 → 0.14.3
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 +497 -348
- package/dist/index.js +16 -1
- package/dist/utils/github-api.js +44 -4
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -24,7 +24,19 @@ import { trackError, trackSetupEvent } from '../utils/telemetry.js';
|
|
|
24
24
|
// Constants
|
|
25
25
|
// =============================================================================
|
|
26
26
|
const HAYSTACK_API = 'https://haystackeditor.com';
|
|
27
|
+
// User-level GitHub calls (repo listing, App-installation check, token
|
|
28
|
+
// verification) go direct: they're scoped to the authenticated user, so OAuth
|
|
29
|
+
// App Policy org restrictions don't apply to them.
|
|
27
30
|
const GITHUB_API = 'https://api.github.com';
|
|
31
|
+
// Per-repo reads + writes go through the auth-worker proxy, which swaps in the
|
|
32
|
+
// GitHub App installation token server-side. That's what lets the config write
|
|
33
|
+
// and the bootstrap PR succeed on orgs that restrict the OAuth app — the same
|
|
34
|
+
// path the web onboarding wizard uses. Authenticated with the CLI's GitHub
|
|
35
|
+
// token via `Authorization: Bearer` (the proxy validates it against GitHub
|
|
36
|
+
// /user, then uses its own installation/OAuth token upstream). Commits + PRs
|
|
37
|
+
// created this way are authored by haystack[bot], which is the intended
|
|
38
|
+
// behavior for onboarding config and matches the web wizard.
|
|
39
|
+
const GITHUB_PROXY = `${HAYSTACK_API}/api/github`;
|
|
28
40
|
const CATEGORY_LABELS = {
|
|
29
41
|
testing: 'Testing',
|
|
30
42
|
style: 'Style',
|
|
@@ -33,16 +45,11 @@ const CATEGORY_LABELS = {
|
|
|
33
45
|
docs: 'Documentation',
|
|
34
46
|
other: 'Other',
|
|
35
47
|
};
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const POLICY_TYPE_LABELS = {
|
|
42
|
-
approval_count: 'Approval Count',
|
|
43
|
-
codeowner_review: 'Codeowner Review',
|
|
44
|
-
team_review: 'Team Review',
|
|
45
|
-
area_owner: 'Area Owner',
|
|
48
|
+
const SEVERITY_LABELS = {
|
|
49
|
+
critical: 'Critical',
|
|
50
|
+
high: 'High',
|
|
51
|
+
medium: 'Medium',
|
|
52
|
+
low: 'Low',
|
|
46
53
|
};
|
|
47
54
|
async function fetchUserRepos(token) {
|
|
48
55
|
const repos = [];
|
|
@@ -71,27 +78,6 @@ async function fetchUserRepos(token) {
|
|
|
71
78
|
.filter((r) => !r.archived)
|
|
72
79
|
.map((r) => r.full_name);
|
|
73
80
|
}
|
|
74
|
-
async function fetchExistingConfig(owner, repo, token) {
|
|
75
|
-
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
|
|
76
|
-
headers: {
|
|
77
|
-
Authorization: `Bearer ${token}`,
|
|
78
|
-
Accept: 'application/vnd.github.v3+json',
|
|
79
|
-
'User-Agent': 'Haystack-CLI',
|
|
80
|
-
},
|
|
81
|
-
});
|
|
82
|
-
if (response.status === 404)
|
|
83
|
-
return null;
|
|
84
|
-
if (!response.ok)
|
|
85
|
-
throw new Error(`Failed to fetch config: ${response.status}`);
|
|
86
|
-
const data = (await response.json());
|
|
87
|
-
const raw = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
88
|
-
try {
|
|
89
|
-
return { config: JSON.parse(raw), sha: data.sha };
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
throw new Error('existing .haystack.json is malformed JSON — delete or fix it manually');
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
81
|
// Bootstrap PR constants — kept in sync with the web wizard
|
|
96
82
|
// (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
|
|
97
83
|
// the two labels are load-bearing: the worker's bootstrap merge path
|
|
@@ -100,14 +86,19 @@ const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
|
|
|
100
86
|
const AUTO_MERGE_LABEL = 'haystack:auto-merge';
|
|
101
87
|
const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
|
|
102
88
|
const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
|
|
103
|
-
const
|
|
89
|
+
const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
|
|
104
90
|
'It adds the Haystack onboarding config:\n' +
|
|
105
|
-
'- `.haystack.
|
|
106
|
-
'- `.
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
|
|
91
|
+
'- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
|
|
92
|
+
'- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
|
|
93
|
+
'- `.haystack.json` — your Haystack merge-queue settings\n' +
|
|
94
|
+
'- `.entire/settings.json` — enables AI-session trace capture (powers intent-drift triage)\n\n';
|
|
95
|
+
// Auto-merge ON: Haystack bootstrap-merges the PR itself once labeled.
|
|
96
|
+
const BOOTSTRAP_PR_OUTRO_AUTOMERGE = 'Haystack will bootstrap-merge this PR automatically once the labels are applied. ' +
|
|
110
97
|
'You can tweak the files from GitHub or re-run `haystack setup` anytime.';
|
|
98
|
+
// Auto-merge OFF: the repo opted out of auto-merge, so this PR is NOT enrolled
|
|
99
|
+
// (no labels). It waits for the user to review and merge it themselves.
|
|
100
|
+
const BOOTSTRAP_PR_OUTRO_MANUAL = 'Auto-merge is off for this repo, so this PR is yours to review: check the files and merge it ' +
|
|
101
|
+
'whenever you\'re ready. You can tweak them from GitHub or re-run `haystack setup` anytime.';
|
|
111
102
|
/**
|
|
112
103
|
* Encode a branch name for use in a URL path while preserving `/`
|
|
113
104
|
* separators (e.g. `release/stable`). encodeURIComponent on the whole
|
|
@@ -135,7 +126,7 @@ async function isBranchProtected(owner, repo, branch, token) {
|
|
|
135
126
|
const headers = ghHeaders(token);
|
|
136
127
|
const enc = encodeBranchPath(branch);
|
|
137
128
|
// Legacy branch protection — the `protected` boolean is authoritative.
|
|
138
|
-
const branchRes = await fetch(`${
|
|
129
|
+
const branchRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/branches/${enc}`, { headers });
|
|
139
130
|
if (branchRes.ok) {
|
|
140
131
|
const data = (await branchRes.json());
|
|
141
132
|
if (data.protected === true)
|
|
@@ -146,7 +137,7 @@ async function isBranchProtected(owner, repo, branch, token) {
|
|
|
146
137
|
}
|
|
147
138
|
// Repository rulesets — `rules/branches` collapses every active rule on
|
|
148
139
|
// the branch into one list. A `pull_request` rule = "must merge via PR".
|
|
149
|
-
const rulesRes = await fetch(`${
|
|
140
|
+
const rulesRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
|
|
150
141
|
if (rulesRes.ok) {
|
|
151
142
|
const rules = (await rulesRes.json());
|
|
152
143
|
if (rules.some((r) => r.type === 'pull_request'))
|
|
@@ -166,7 +157,7 @@ function ghHeaders(token) {
|
|
|
166
157
|
};
|
|
167
158
|
}
|
|
168
159
|
async function getDefaultBranch(owner, repo, token) {
|
|
169
|
-
const res = await fetch(`${
|
|
160
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
|
|
170
161
|
if (!res.ok) {
|
|
171
162
|
throw new Error(`Failed to read repo metadata: ${res.status} — ${await res.text()}`);
|
|
172
163
|
}
|
|
@@ -177,14 +168,14 @@ async function getBranchSha(owner, repo, branch, token) {
|
|
|
177
168
|
// path — encodeURIComponent would turn `release/stable` into
|
|
178
169
|
// `release%2Fstable` and 404. encodeBranchPath escapes each segment but
|
|
179
170
|
// keeps the `/` separators.
|
|
180
|
-
const res = await fetch(`${
|
|
171
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/ref/heads/${encodeBranchPath(branch)}`, { headers: ghHeaders(token) });
|
|
181
172
|
if (!res.ok) {
|
|
182
173
|
throw new Error(`Failed to read branch ref: ${res.status} — ${await res.text()}`);
|
|
183
174
|
}
|
|
184
175
|
return (await res.json()).object.sha;
|
|
185
176
|
}
|
|
186
177
|
async function createBranch(owner, repo, branch, sha, token) {
|
|
187
|
-
const res = await fetch(`${
|
|
178
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/refs`, {
|
|
188
179
|
method: 'POST',
|
|
189
180
|
headers: ghHeaders(token),
|
|
190
181
|
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
|
|
@@ -193,11 +184,13 @@ async function createBranch(owner, repo, branch, sha, token) {
|
|
|
193
184
|
throw new Error(`Failed to create branch: ${res.status} — ${await res.text()}`);
|
|
194
185
|
}
|
|
195
186
|
}
|
|
196
|
-
async function createBootstrapPR(owner, repo, head, base, token) {
|
|
197
|
-
const
|
|
187
|
+
async function createBootstrapPR(owner, repo, head, base, token, autoMergeEnabled) {
|
|
188
|
+
const body = BOOTSTRAP_PR_BODY_INTRO +
|
|
189
|
+
(autoMergeEnabled ? BOOTSTRAP_PR_OUTRO_AUTOMERGE : BOOTSTRAP_PR_OUTRO_MANUAL);
|
|
190
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/pulls`, {
|
|
198
191
|
method: 'POST',
|
|
199
192
|
headers: ghHeaders(token),
|
|
200
|
-
body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body
|
|
193
|
+
body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body }),
|
|
201
194
|
});
|
|
202
195
|
if (!res.ok) {
|
|
203
196
|
throw new Error(`Failed to open PR: ${res.status} — ${await res.text()}`);
|
|
@@ -222,14 +215,14 @@ async function createBootstrapPR(owner, repo, head, base, token) {
|
|
|
222
215
|
async function createCommitWithFiles(owner, repo, baseSha, files, message, token) {
|
|
223
216
|
const headers = ghHeaders(token);
|
|
224
217
|
// The new tree extends the base commit's tree.
|
|
225
|
-
const baseCommitRes = await fetch(`${
|
|
218
|
+
const baseCommitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits/${baseSha}`, { headers });
|
|
226
219
|
if (!baseCommitRes.ok) {
|
|
227
220
|
throw new Error(`Failed to read base commit: ${baseCommitRes.status} — ${await baseCommitRes.text()}`);
|
|
228
221
|
}
|
|
229
222
|
const baseTreeSha = (await baseCommitRes.json()).tree.sha;
|
|
230
223
|
// One blob per file.
|
|
231
224
|
const treeEntries = await Promise.all(files.map(async (f) => {
|
|
232
|
-
const blobRes = await fetch(`${
|
|
225
|
+
const blobRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/blobs`, {
|
|
233
226
|
method: 'POST',
|
|
234
227
|
headers,
|
|
235
228
|
body: JSON.stringify({ content: f.content, encoding: 'utf-8' }),
|
|
@@ -240,7 +233,7 @@ async function createCommitWithFiles(owner, repo, baseSha, files, message, token
|
|
|
240
233
|
const blob = (await blobRes.json());
|
|
241
234
|
return { path: f.path, mode: '100644', type: 'blob', sha: blob.sha };
|
|
242
235
|
}));
|
|
243
|
-
const treeRes = await fetch(`${
|
|
236
|
+
const treeRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/trees`, {
|
|
244
237
|
method: 'POST',
|
|
245
238
|
headers,
|
|
246
239
|
body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
|
|
@@ -249,7 +242,7 @@ async function createCommitWithFiles(owner, repo, baseSha, files, message, token
|
|
|
249
242
|
throw new Error(`Failed to create tree: ${treeRes.status} — ${await treeRes.text()}`);
|
|
250
243
|
}
|
|
251
244
|
const tree = (await treeRes.json());
|
|
252
|
-
const commitRes = await fetch(`${
|
|
245
|
+
const commitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits`, {
|
|
253
246
|
method: 'POST',
|
|
254
247
|
headers,
|
|
255
248
|
body: JSON.stringify({ message, tree: tree.sha, parents: [baseSha] }),
|
|
@@ -280,7 +273,7 @@ async function buildEntireSettingsContent(repoFullName, token) {
|
|
|
280
273
|
let originalContent = '';
|
|
281
274
|
let exists = false;
|
|
282
275
|
let existingSha = null;
|
|
283
|
-
const getResp = await fetch(`${
|
|
276
|
+
const getResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, { headers });
|
|
284
277
|
if (getResp.ok) {
|
|
285
278
|
exists = true;
|
|
286
279
|
const data = (await getResp.json());
|
|
@@ -325,7 +318,7 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
|
|
|
325
318
|
const labels = [AUTO_MERGE_LABEL, ONBOARDING_BOOTSTRAP_LABEL];
|
|
326
319
|
let lastErr = '';
|
|
327
320
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
328
|
-
const res = await fetch(`${
|
|
321
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
|
|
329
322
|
method: 'POST',
|
|
330
323
|
headers: ghHeaders(token),
|
|
331
324
|
body: JSON.stringify({ labels }),
|
|
@@ -345,28 +338,70 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
|
|
|
345
338
|
`Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
|
|
346
339
|
`Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
|
|
347
340
|
}
|
|
341
|
+
/** Wraps a contents-API write failure with its HTTP status so the caller can
|
|
342
|
+
* distinguish a protection rejection (409/422 → PR fallback) from a genuine
|
|
343
|
+
* error (→ surface it). */
|
|
344
|
+
class GitHubWriteError extends Error {
|
|
345
|
+
status;
|
|
346
|
+
body;
|
|
347
|
+
constructor(status, body) {
|
|
348
|
+
super(`GitHub write failed: ${status}`);
|
|
349
|
+
this.status = status;
|
|
350
|
+
this.body = body;
|
|
351
|
+
this.name = 'GitHubWriteError';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Read the blob SHA of an existing file (needed to update it). Non-OK (incl.
|
|
355
|
+
* 404 = file doesn't exist yet) returns null; the subsequent PUT then creates
|
|
356
|
+
* the file, and surfaces a real error if the write itself fails. */
|
|
357
|
+
async function fetchFileSha(owner, repo, path, token) {
|
|
358
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, { headers: ghHeaders(token) });
|
|
359
|
+
if (!res.ok)
|
|
360
|
+
return null;
|
|
361
|
+
const data = (await res.json());
|
|
362
|
+
return data.sha ?? null;
|
|
363
|
+
}
|
|
364
|
+
/** PUT a single file to the default branch via the contents API. Throws
|
|
365
|
+
* GitHubWriteError on any non-OK response so the caller can branch on status. */
|
|
366
|
+
async function putFile(owner, repo, path, content, message, sha, token) {
|
|
367
|
+
const body = {
|
|
368
|
+
message,
|
|
369
|
+
content: Buffer.from(content).toString('base64'),
|
|
370
|
+
};
|
|
371
|
+
if (sha)
|
|
372
|
+
body.sha = sha;
|
|
373
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, {
|
|
374
|
+
method: 'PUT',
|
|
375
|
+
headers: ghHeaders(token),
|
|
376
|
+
body: JSON.stringify(body),
|
|
377
|
+
});
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
throw new GitHubWriteError(res.status, await res.text().catch(() => ''));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
348
382
|
/**
|
|
349
|
-
* Protected-branch fallback: build ONE commit containing
|
|
350
|
-
* files (
|
|
351
|
-
*
|
|
352
|
-
* labels.
|
|
383
|
+
* Protected-branch fallback: build ONE commit containing ALL the onboarding
|
|
384
|
+
* files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
|
|
385
|
+
* a branch ref at it, open a PR, and apply the auto-merge + bootstrap labels.
|
|
353
386
|
*
|
|
354
|
-
* Why
|
|
387
|
+
* Why every file in one commit: a ruleset whose `pull_request` rule targets
|
|
355
388
|
* `~ALL` branches blocks ref *updates* on every branch — so you can neither
|
|
356
389
|
* commit to the default branch NOR add a second commit to the onboarding
|
|
357
|
-
* branch afterward. The only thing allowed is *creating* a ref. So
|
|
358
|
-
* bootstrap-critical files (`.haystack.
|
|
359
|
-
* `.
|
|
360
|
-
* runs on) must land in the single commit the
|
|
390
|
+
* branch afterward. The only thing allowed is *creating* a ref. So all the
|
|
391
|
+
* bootstrap-critical files (`.haystack/pr-rules.yml`, `.haystack/review-policy.md`,
|
|
392
|
+
* `.haystack.json` enables the merge queue, `.entire/settings.json` enables the
|
|
393
|
+
* trace capture intent-drift triage runs on) must land in the single commit the
|
|
394
|
+
* branch is created at. Mirrors the web wizard's PR-fallback path.
|
|
361
395
|
*/
|
|
362
|
-
async function openBootstrapConfigPR(owner, repo,
|
|
396
|
+
async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled) {
|
|
363
397
|
const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
|
|
364
398
|
// Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
|
|
365
399
|
// The timestamp avoids collisions when setup is re-run.
|
|
366
400
|
const branchName = `haystack/onboarding-${Date.now()}`;
|
|
367
|
-
const files =
|
|
368
|
-
|
|
369
|
-
|
|
401
|
+
const files = configFiles.map((f) => ({
|
|
402
|
+
path: f.path,
|
|
403
|
+
content: f.content,
|
|
404
|
+
}));
|
|
370
405
|
// Bundle .entire/settings.json into the same commit — on a ~ALL ruleset
|
|
371
406
|
// we can't add it later. It's repo-level config (independent of whether
|
|
372
407
|
// this dev installs the Entire binary locally), so it rides along here.
|
|
@@ -399,137 +434,137 @@ async function openBootstrapConfigPR(owner, repo, configContent, token, defaultB
|
|
|
399
434
|
}
|
|
400
435
|
const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
|
|
401
436
|
await createBranch(owner, repo, branchName, commitSha, token);
|
|
402
|
-
const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token);
|
|
403
|
-
|
|
437
|
+
const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
|
|
438
|
+
// Only enroll the PR for auto-merge when the repo hasn't opted out. Applying
|
|
439
|
+
// the labels on an auto-merge-disabled repo would force-merge a config PR the
|
|
440
|
+
// user wants to review themselves (e.g. a repo set auto-merge-off via the web
|
|
441
|
+
// wizard, then re-run through `haystack setup`). Without labels the worker
|
|
442
|
+
// never picks it up; the PR is still opened and analyzed, it just waits.
|
|
443
|
+
if (autoMergeEnabled) {
|
|
444
|
+
await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
|
|
445
|
+
}
|
|
404
446
|
return { prUrl: pr.html_url, entireSettingsBundled };
|
|
405
447
|
}
|
|
406
448
|
/**
|
|
407
|
-
* Write
|
|
408
|
-
* branch first; on a protected-branch / ruleset
|
|
409
|
-
*
|
|
449
|
+
* Write the onboarding config files to a repo. Tries a direct commit per file
|
|
450
|
+
* to the default branch first; on a protected-branch / ruleset rejection
|
|
451
|
+
* (409/422 confirmed by a structured protection probe) falls back to ONE
|
|
452
|
+
* bootstrap PR containing all files. Mirrors the web wizard's
|
|
453
|
+
* writeOnboardingFiles.
|
|
410
454
|
*
|
|
411
455
|
* Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
|
|
412
|
-
* thread the branch into step 7), `{}` on
|
|
456
|
+
* thread the branch into step 7), `{}` on clean direct commits.
|
|
413
457
|
*/
|
|
414
|
-
async function
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
});
|
|
427
|
-
if (response.ok)
|
|
458
|
+
async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
|
|
459
|
+
// Look up existing SHAs once on the default branch so updates to files that
|
|
460
|
+
// already exist carry the right sha. Done before any write so a mid-loop
|
|
461
|
+
// failure doesn't leave us guessing.
|
|
462
|
+
const shaByPath = new Map();
|
|
463
|
+
for (const f of files) {
|
|
464
|
+
shaByPath.set(f.path, await fetchFileSha(owner, repo, f.path, token));
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
for (const f of files) {
|
|
468
|
+
await putFile(owner, repo, f.path, f.content, f.message, shaByPath.get(f.path) ?? null, token);
|
|
469
|
+
}
|
|
428
470
|
return {};
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
// Only a 409/422 can be a protection rejection. Anything else (auth, 404,
|
|
474
|
+
// 5xx, network) is a genuine error — surface it.
|
|
475
|
+
const status = err instanceof GitHubWriteError ? err.status : 0;
|
|
476
|
+
if (status !== 409 && status !== 422)
|
|
477
|
+
throw err;
|
|
478
|
+
// Probe GitHub's *structured* protection signals to decide: protection
|
|
479
|
+
// (→ PR fallback) vs. a genuine bad request / sha conflict (→ surface the
|
|
480
|
+
// error). This replaces brittle error-message substring matching.
|
|
436
481
|
const defaultBranch = await getDefaultBranch(owner, repo, token);
|
|
437
|
-
if (await isBranchProtected(owner, repo, defaultBranch, token)) {
|
|
438
|
-
|
|
439
|
-
const bootstrapPR = await openBootstrapConfigPR(owner, repo, content, token, defaultBranch);
|
|
440
|
-
return { bootstrapPR };
|
|
482
|
+
if (!(await isBranchProtected(owner, repo, defaultBranch, token))) {
|
|
483
|
+
throw err;
|
|
441
484
|
}
|
|
485
|
+
const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
|
|
486
|
+
return { bootstrapPR };
|
|
442
487
|
}
|
|
443
|
-
throw new Error(`Failed to write config: ${response.status} — ${err}`);
|
|
444
488
|
}
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
489
|
+
const SCAN_POLL_INTERVAL_MS = 1500;
|
|
490
|
+
// A run still marked `running` but whose KV state hasn't been touched for this
|
|
491
|
+
// long is treated as dead (CF isolate killed mid-scan). Mirrors the web's
|
|
492
|
+
// RESUME_FRESHNESS window so we surface a retryable error instead of hanging.
|
|
493
|
+
const SCAN_STALE_MS = 90_000;
|
|
448
494
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* the
|
|
495
|
+
* Run one unified onboarding scan for a single repo, returning the proposed
|
|
496
|
+
* rules / policies / instructions. Authenticated with the CLI's GitHub token
|
|
497
|
+
* (the scan endpoint validates it against GitHub /user, the same trust model as
|
|
498
|
+
* the cookie session the web uses).
|
|
452
499
|
*/
|
|
453
|
-
function
|
|
454
|
-
const
|
|
455
|
-
for (const key of Object.keys(source)) {
|
|
456
|
-
const srcVal = source[key];
|
|
457
|
-
const tgtVal = result[key];
|
|
458
|
-
if (srcVal !== null &&
|
|
459
|
-
typeof srcVal === 'object' &&
|
|
460
|
-
!Array.isArray(srcVal) &&
|
|
461
|
-
tgtVal !== null &&
|
|
462
|
-
typeof tgtVal === 'object' &&
|
|
463
|
-
!Array.isArray(tgtVal)) {
|
|
464
|
-
result[key] = deepMerge(tgtVal, srcVal);
|
|
465
|
-
}
|
|
466
|
-
else {
|
|
467
|
-
result[key] = srcVal;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return result;
|
|
471
|
-
}
|
|
472
|
-
// =============================================================================
|
|
473
|
-
// Onboarding scan (SSE client)
|
|
474
|
-
// =============================================================================
|
|
475
|
-
async function runScan(repos, scanType, token) {
|
|
476
|
-
const response = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
|
|
500
|
+
async function runUnifiedScan(repo, token) {
|
|
501
|
+
const startResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
|
|
477
502
|
method: 'POST',
|
|
478
503
|
headers: {
|
|
479
504
|
'Content-Type': 'application/json',
|
|
480
505
|
Authorization: `Bearer ${token}`,
|
|
481
506
|
},
|
|
482
|
-
body: JSON.stringify({
|
|
507
|
+
body: JSON.stringify({ repo, scanType: 'unified' }),
|
|
483
508
|
});
|
|
484
|
-
if (!
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const eventData = eventDataLines.join('\n');
|
|
499
|
-
const parsed = JSON.parse(eventData);
|
|
500
|
-
if (eventType === 'progress') {
|
|
501
|
-
const progress = parsed;
|
|
502
|
-
process.stdout.write(`\r${chalk.dim(' ' + progress.message)}`);
|
|
503
|
-
if (progress.detail) {
|
|
504
|
-
process.stdout.write(chalk.dim(` — ${progress.detail}`));
|
|
505
|
-
}
|
|
506
|
-
process.stdout.write('\x1b[K');
|
|
509
|
+
if (!startResp.ok) {
|
|
510
|
+
const text = await startResp.text().catch(() => '');
|
|
511
|
+
throw new Error(`Scan kickoff failed: ${startResp.status}${text ? ` ${text}` : ''}`.trim());
|
|
512
|
+
}
|
|
513
|
+
const { runId } = (await startResp.json());
|
|
514
|
+
if (!runId)
|
|
515
|
+
throw new Error('Scan kickoff returned no runId');
|
|
516
|
+
let lastProgressKey = '';
|
|
517
|
+
// eslint-disable-next-line no-constant-condition
|
|
518
|
+
while (true) {
|
|
519
|
+
await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_INTERVAL_MS));
|
|
520
|
+
let statusResp;
|
|
521
|
+
try {
|
|
522
|
+
statusResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan/status?runId=${encodeURIComponent(runId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
507
523
|
}
|
|
508
|
-
|
|
509
|
-
|
|
524
|
+
catch {
|
|
525
|
+
// Transient network blip — the KV state is authoritative and won't vanish
|
|
526
|
+
// from one failed poll, so keep going.
|
|
527
|
+
continue;
|
|
510
528
|
}
|
|
511
|
-
|
|
512
|
-
|
|
529
|
+
if (statusResp.status === 404) {
|
|
530
|
+
// State expired (TTL) or runId never registered — surface, don't hang.
|
|
531
|
+
process.stdout.write('\r\x1b[K');
|
|
532
|
+
throw new Error('Scan state expired — please retry.');
|
|
513
533
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
534
|
+
if (!statusResp.ok) {
|
|
535
|
+
// Transient (5xx etc.) — back off and retry on the next tick.
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const state = (await statusResp.json());
|
|
539
|
+
if (state.progress) {
|
|
540
|
+
const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
|
|
541
|
+
if (key !== lastProgressKey) {
|
|
542
|
+
lastProgressKey = key;
|
|
543
|
+
process.stdout.write(`\r${chalk.dim(' ' + state.progress.message)}`);
|
|
544
|
+
if (state.progress.detail)
|
|
545
|
+
process.stdout.write(chalk.dim(` — ${state.progress.detail}`));
|
|
546
|
+
process.stdout.write('\x1b[K');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (state.status === 'done') {
|
|
550
|
+
process.stdout.write('\r\x1b[K');
|
|
551
|
+
if (!state.result)
|
|
552
|
+
throw new Error('Scan completed but returned no result.');
|
|
553
|
+
return {
|
|
554
|
+
rules: state.result.rules ?? [],
|
|
555
|
+
policies: state.result.policies ?? [],
|
|
556
|
+
instructions: state.result.instructions ?? [],
|
|
557
|
+
};
|
|
520
558
|
}
|
|
521
|
-
|
|
522
|
-
|
|
559
|
+
if (state.status === 'error') {
|
|
560
|
+
process.stdout.write('\r\x1b[K');
|
|
561
|
+
throw new Error(state.error || 'Scan failed');
|
|
523
562
|
}
|
|
524
|
-
|
|
525
|
-
|
|
563
|
+
if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
|
|
564
|
+
process.stdout.write('\r\x1b[K');
|
|
565
|
+
throw new Error('Scan stalled (no progress) — please retry.');
|
|
526
566
|
}
|
|
527
567
|
}
|
|
528
|
-
// Flush any remaining buffered event at EOF (stream may not end with blank line)
|
|
529
|
-
flushEvent();
|
|
530
|
-
// Clear progress line
|
|
531
|
-
process.stdout.write('\r\x1b[K');
|
|
532
|
-
return items;
|
|
533
568
|
}
|
|
534
569
|
// =============================================================================
|
|
535
570
|
// Display helpers
|
|
@@ -544,19 +579,18 @@ function printRule(rule, index) {
|
|
|
544
579
|
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${rule.name} ${cat}`);
|
|
545
580
|
console.log(` ${chalk.dim(rule.description)}`);
|
|
546
581
|
}
|
|
547
|
-
function printSignal(signal, index) {
|
|
548
|
-
const status = signal.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
549
|
-
const type = chalk.dim(`[${SIGNAL_TYPE_LABELS[signal.type] || signal.type}]`);
|
|
550
|
-
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${signal.name} ${type}`);
|
|
551
|
-
if (signal.pattern) {
|
|
552
|
-
console.log(` ${chalk.dim(`Pattern: ${signal.pattern}`)}`);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
582
|
function printPolicy(policy, index) {
|
|
556
583
|
const status = policy.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
557
|
-
const
|
|
558
|
-
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${
|
|
559
|
-
|
|
584
|
+
const sev = chalk.dim(`[${SEVERITY_LABELS[policy.severity] || policy.severity}]`);
|
|
585
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${sev}`);
|
|
586
|
+
if (policy.paths.length > 0) {
|
|
587
|
+
console.log(` ${chalk.dim(`Paths: ${policy.paths.join(', ')}`)}`);
|
|
588
|
+
}
|
|
589
|
+
console.log(` ${chalk.dim(policy.reason)}`);
|
|
590
|
+
}
|
|
591
|
+
function printInstruction(instruction, index) {
|
|
592
|
+
const status = instruction.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
593
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${instruction.text}`);
|
|
560
594
|
}
|
|
561
595
|
// =============================================================================
|
|
562
596
|
// Wizard steps
|
|
@@ -745,51 +779,76 @@ async function stepSelectRepos(token) {
|
|
|
745
779
|
console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
|
|
746
780
|
return selectedRepos;
|
|
747
781
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
782
|
+
// File paths written during onboarding (must match the web wizard + worker:
|
|
783
|
+
// src/features/onboarding/services/onboardingApi.ts + the merge-queue cron's
|
|
784
|
+
// ONBOARDING_ALLOWED_PATHS).
|
|
785
|
+
const PR_RULES_PATH = '.haystack/pr-rules.yml';
|
|
786
|
+
const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
|
|
787
|
+
const HAYSTACK_CONFIG_PATH = '.haystack.json';
|
|
788
|
+
const RULES_COMMIT_MSG = 'chore: configure Haystack rules via CLI setup wizard';
|
|
789
|
+
const POLICY_COMMIT_MSG = 'chore: add review policies via CLI setup wizard';
|
|
790
|
+
/**
|
|
791
|
+
* Scan each selected repo with one unified scan and collect the proposed
|
|
792
|
+
* rules / policies / instructions per repo. A failed scan for one repo records
|
|
793
|
+
* an empty result (and a warning) rather than aborting the whole wizard.
|
|
794
|
+
*/
|
|
795
|
+
async function stepScanRepos(repos, token) {
|
|
796
|
+
console.log(chalk.bold('\n Step 2: Scanning your repositories\n'));
|
|
797
|
+
console.log(chalk.dim(" Haystack reads each repo's PR history to learn its review conventions.\n"));
|
|
798
|
+
const byRepo = new Map();
|
|
799
|
+
for (const repo of repos) {
|
|
800
|
+
console.log(chalk.dim(` Scanning ${repo}...`));
|
|
801
|
+
try {
|
|
802
|
+
const result = await runUnifiedScan(repo, token);
|
|
803
|
+
byRepo.set(repo, result);
|
|
804
|
+
const counts = [];
|
|
805
|
+
if (result.rules.length)
|
|
806
|
+
counts.push(`${result.rules.length} rule(s)`);
|
|
807
|
+
if (result.policies.length)
|
|
808
|
+
counts.push(`${result.policies.length} policy/policies`);
|
|
809
|
+
if (result.instructions.length)
|
|
810
|
+
counts.push(`${result.instructions.length} instruction(s)`);
|
|
811
|
+
console.log(counts.length
|
|
812
|
+
? chalk.green(` ✓ ${repo}: ${counts.join(', ')}`)
|
|
813
|
+
: chalk.dim(` ${repo}: nothing discovered`));
|
|
759
814
|
}
|
|
760
|
-
|
|
761
|
-
console.log(chalk.
|
|
815
|
+
catch (err) {
|
|
816
|
+
console.log(chalk.yellow(` ⚠ ${repo}: scan failed (${err.message})`));
|
|
817
|
+
console.log(chalk.dim(' Continuing — this repo will get a baseline config you can tune later.'));
|
|
818
|
+
byRepo.set(repo, { rules: [], policies: [], instructions: [] });
|
|
762
819
|
}
|
|
763
|
-
return items;
|
|
764
|
-
}
|
|
765
|
-
catch (err) {
|
|
766
|
-
console.log(chalk.yellow(` Scan failed: ${err.message}`));
|
|
767
|
-
console.log(chalk.dim(' Skipping this scan — you can configure manually later.\n'));
|
|
768
|
-
return [];
|
|
769
820
|
}
|
|
821
|
+
return byRepo;
|
|
770
822
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
823
|
+
/**
|
|
824
|
+
* Show the discovered rules / policies / instructions per repo and let the user
|
|
825
|
+
* toggle any off. Mutates the `enabled` flags on the items in `byRepo` in place.
|
|
826
|
+
*/
|
|
827
|
+
async function stepReview(byRepo) {
|
|
828
|
+
const total = [...byRepo.values()].reduce((n, r) => n + r.rules.length + r.policies.length + r.instructions.length, 0);
|
|
829
|
+
if (total === 0) {
|
|
774
830
|
console.log(chalk.dim('\n No items discovered — skipping review.\n'));
|
|
775
|
-
return
|
|
831
|
+
return;
|
|
776
832
|
}
|
|
777
|
-
console.log(chalk.bold('\n Step
|
|
833
|
+
console.log(chalk.bold('\n Step 3: Review discovered configuration\n'));
|
|
778
834
|
console.log(chalk.dim(' Toggle items on/off. Only enabled items will be written.\n'));
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
835
|
+
for (const [repo, result] of byRepo) {
|
|
836
|
+
if (result.rules.length + result.policies.length + result.instructions.length === 0)
|
|
837
|
+
continue;
|
|
838
|
+
console.log(`\n ${chalk.bold.cyan(repo)}`);
|
|
839
|
+
if (result.rules.length > 0) {
|
|
840
|
+
printSectionHeader('Coding Rules', result.rules.filter((r) => r.enabled).length, result.rules.length);
|
|
841
|
+
result.rules.forEach((r, i) => printRule(r, i));
|
|
842
|
+
}
|
|
843
|
+
if (result.policies.length > 0) {
|
|
844
|
+
printSectionHeader('Review Policies', result.policies.filter((p) => p.enabled).length, result.policies.length);
|
|
845
|
+
result.policies.forEach((p, i) => printPolicy(p, i));
|
|
846
|
+
}
|
|
847
|
+
if (result.instructions.length > 0) {
|
|
848
|
+
printSectionHeader('Review Instructions', result.instructions.filter((ins) => ins.enabled).length, result.instructions.length);
|
|
849
|
+
result.instructions.forEach((ins, i) => printInstruction(ins, i));
|
|
850
|
+
}
|
|
791
851
|
}
|
|
792
|
-
// Ask if they want to toggle anything
|
|
793
852
|
const { wantToToggle } = await inquirer.prompt([
|
|
794
853
|
{
|
|
795
854
|
type: 'confirm',
|
|
@@ -798,127 +857,215 @@ async function stepReview(rules, signals, policies) {
|
|
|
798
857
|
default: false,
|
|
799
858
|
},
|
|
800
859
|
]);
|
|
801
|
-
if (wantToToggle)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
signals.forEach((s) => {
|
|
817
|
-
allItems.push({
|
|
818
|
-
name: `${s.name} ${chalk.dim(`[${SIGNAL_TYPE_LABELS[s.type] || s.type}]`)}`,
|
|
819
|
-
value: `signal:${s.id}`,
|
|
820
|
-
checked: s.enabled,
|
|
821
|
-
});
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
if (policies.length > 0) {
|
|
825
|
-
allItems.push(new inquirer.Separator('── Review Policies ──'));
|
|
826
|
-
policies.forEach((p) => {
|
|
827
|
-
allItems.push({
|
|
828
|
-
name: `${p.name} ${chalk.dim(`[${POLICY_TYPE_LABELS[p.type] || p.type}]`)}`,
|
|
829
|
-
value: `policy:${p.id}`,
|
|
830
|
-
checked: p.enabled,
|
|
831
|
-
});
|
|
860
|
+
if (!wantToToggle)
|
|
861
|
+
return;
|
|
862
|
+
// NUL separates repo / kind / id in the checkbox value so it can't collide
|
|
863
|
+
// with any character in a repo slug or an item id.
|
|
864
|
+
const SEP = '\u0000';
|
|
865
|
+
const allItems = [];
|
|
866
|
+
for (const [repo, result] of byRepo) {
|
|
867
|
+
if (result.rules.length + result.policies.length + result.instructions.length === 0)
|
|
868
|
+
continue;
|
|
869
|
+
allItems.push(new inquirer.Separator(`── ${repo} ──`));
|
|
870
|
+
result.rules.forEach((r) => {
|
|
871
|
+
allItems.push({
|
|
872
|
+
name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
|
|
873
|
+
value: `${repo}${SEP}rule${SEP}${r.id}`,
|
|
874
|
+
checked: r.enabled,
|
|
832
875
|
});
|
|
833
|
-
}
|
|
834
|
-
const { enabled } = await inquirer.prompt([
|
|
835
|
-
{
|
|
836
|
-
type: 'checkbox',
|
|
837
|
-
name: 'enabled',
|
|
838
|
-
message: 'Select items to enable:',
|
|
839
|
-
choices: allItems,
|
|
840
|
-
pageSize: 20,
|
|
841
|
-
},
|
|
842
|
-
]);
|
|
843
|
-
// Apply toggling
|
|
844
|
-
rules.forEach((r) => {
|
|
845
|
-
r.enabled = enabled.includes(`rule:${r.id}`);
|
|
846
876
|
});
|
|
847
|
-
|
|
848
|
-
|
|
877
|
+
result.policies.forEach((p) => {
|
|
878
|
+
allItems.push({
|
|
879
|
+
name: `${p.name} ${chalk.dim(`[policy · ${SEVERITY_LABELS[p.severity] || p.severity}]`)}`,
|
|
880
|
+
value: `${repo}${SEP}policy${SEP}${p.id}`,
|
|
881
|
+
checked: p.enabled,
|
|
882
|
+
});
|
|
849
883
|
});
|
|
850
|
-
|
|
851
|
-
|
|
884
|
+
result.instructions.forEach((ins) => {
|
|
885
|
+
allItems.push({
|
|
886
|
+
name: `${ins.text} ${chalk.dim('[instruction]')}`,
|
|
887
|
+
value: `${repo}${SEP}instruction${SEP}${ins.id}`,
|
|
888
|
+
checked: ins.enabled,
|
|
889
|
+
});
|
|
852
890
|
});
|
|
853
891
|
}
|
|
854
|
-
|
|
892
|
+
const { enabled } = await inquirer.prompt([
|
|
893
|
+
{
|
|
894
|
+
type: 'checkbox',
|
|
895
|
+
name: 'enabled',
|
|
896
|
+
message: 'Select items to enable:',
|
|
897
|
+
choices: allItems,
|
|
898
|
+
pageSize: 20,
|
|
899
|
+
},
|
|
900
|
+
]);
|
|
901
|
+
const enabledSet = new Set(enabled);
|
|
902
|
+
for (const [repo, result] of byRepo) {
|
|
903
|
+
result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
|
|
904
|
+
result.policies.forEach((p) => { p.enabled = enabledSet.has(`${repo}${SEP}policy${SEP}${p.id}`); });
|
|
905
|
+
result.instructions.forEach((ins) => { ins.enabled = enabledSet.has(`${repo}${SEP}instruction${SEP}${ins.id}`); });
|
|
906
|
+
}
|
|
855
907
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
908
|
+
// ── Config builders ──────────────────────────────────────────────────────
|
|
909
|
+
// Mirror src/features/onboarding/components/ConfirmStep.tsx (buildPrRules,
|
|
910
|
+
// buildReviewPolicyMarkdown) + services/onboardingApi.ts (serializePrRulesYaml,
|
|
911
|
+
// buildHaystackConfigJson) so the CLI and web wizard emit byte-identical files.
|
|
912
|
+
/** Build pr-rules.yml entries from the proposed rules. Everything onboarding
|
|
913
|
+
* surfaces is LLM-evaluated (natural-language rules extracted from review
|
|
914
|
+
* comments), so type:'llm' and the description doubles as the prompt. */
|
|
915
|
+
function buildPrRules(rules) {
|
|
916
|
+
const PROPOSED_TO_YML_CATEGORY = {
|
|
917
|
+
testing: 'patterns',
|
|
918
|
+
style: 'style',
|
|
919
|
+
security: 'security',
|
|
920
|
+
performance: 'performance',
|
|
921
|
+
docs: 'patterns',
|
|
922
|
+
other: 'patterns',
|
|
859
923
|
};
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
config.wait_for = enabledSignals.map((s) => ({
|
|
872
|
-
name: s.name,
|
|
873
|
-
type: s.type,
|
|
874
|
-
source: s.source,
|
|
875
|
-
pattern: s.pattern,
|
|
876
|
-
}));
|
|
877
|
-
}
|
|
878
|
-
if (enabledPolicies.length > 0) {
|
|
879
|
-
config.review_policies = enabledPolicies.map((p) => ({
|
|
880
|
-
name: p.name,
|
|
881
|
-
type: p.type,
|
|
882
|
-
description: p.description,
|
|
883
|
-
config: p.config,
|
|
884
|
-
}));
|
|
885
|
-
}
|
|
886
|
-
return config;
|
|
924
|
+
return rules
|
|
925
|
+
.filter((r) => r.enabled)
|
|
926
|
+
.map((r) => ({
|
|
927
|
+
id: r.id,
|
|
928
|
+
name: r.name,
|
|
929
|
+
type: 'llm',
|
|
930
|
+
severity: 'warning',
|
|
931
|
+
message: r.description,
|
|
932
|
+
category: PROPOSED_TO_YML_CATEGORY[r.category],
|
|
933
|
+
llm: { prompt: r.description },
|
|
934
|
+
}));
|
|
887
935
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const enabledSignals = signals.filter((s) => s.enabled);
|
|
936
|
+
/** Render review-policy.md from path-scoped policies + universal instructions.
|
|
937
|
+
* Returns null when nothing is enabled (caller skips the file). */
|
|
938
|
+
function buildReviewPolicyMarkdown(policies, instructions) {
|
|
892
939
|
const enabledPolicies = policies.filter((p) => p.enabled);
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
940
|
+
const enabledInstructions = instructions.filter((i) => i.enabled);
|
|
941
|
+
if (enabledPolicies.length === 0 && enabledInstructions.length === 0)
|
|
942
|
+
return null;
|
|
943
|
+
const lines = ['# Review Policies', ''];
|
|
944
|
+
for (const policy of enabledPolicies) {
|
|
945
|
+
lines.push(`## ${policy.name}`);
|
|
946
|
+
lines.push(`- **Paths**: ${policy.paths.map((p) => '`' + p + '`').join(', ')}`);
|
|
947
|
+
lines.push(`- **Severity**: ${policy.severity}`);
|
|
948
|
+
lines.push(`- **Reason**: ${policy.reason}`);
|
|
949
|
+
lines.push('');
|
|
950
|
+
}
|
|
951
|
+
if (enabledInstructions.length > 0) {
|
|
952
|
+
lines.push('## Instructions');
|
|
953
|
+
for (const instr of enabledInstructions) {
|
|
954
|
+
lines.push(`- ${instr.text}`);
|
|
955
|
+
}
|
|
956
|
+
lines.push('');
|
|
905
957
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
958
|
+
return lines.join('\n');
|
|
959
|
+
}
|
|
960
|
+
/** `.haystack.json` enabling the merge queue, reflecting the user's auto-merge
|
|
961
|
+
* choice. Mirrors the web wizard's buildHaystackConfigJson shape. Auto-merge is
|
|
962
|
+
* the master switch: when off, nothing lands in the queue, so the two sub-flags
|
|
963
|
+
* follow it. */
|
|
964
|
+
function buildHaystackConfigJson(autoMerge) {
|
|
965
|
+
const config = {
|
|
966
|
+
preferences: { auto_merge: autoMerge },
|
|
967
|
+
merge_queue: {
|
|
968
|
+
merge_queue: autoMerge,
|
|
969
|
+
auto_resolve_conflicts: autoMerge,
|
|
970
|
+
auto_fix_ci_failures: autoMerge,
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
return JSON.stringify(config, null, 2) + '\n';
|
|
974
|
+
}
|
|
975
|
+
// Minimal YAML serializer for the pr-rules.yml shape (keeps us off a yaml dep;
|
|
976
|
+
// mirrors onboardingApi.ts serializePrRulesYaml).
|
|
977
|
+
function yamlScalar(s) {
|
|
978
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
979
|
+
}
|
|
980
|
+
function yamlBlockOrScalar(s, indentCols) {
|
|
981
|
+
if (!s.includes('\n'))
|
|
982
|
+
return yamlScalar(s);
|
|
983
|
+
const pad = ' '.repeat(indentCols);
|
|
984
|
+
const body = s.split('\n').map((line) => `${pad}${line}`).join('\n');
|
|
985
|
+
return `|\n${body}`;
|
|
986
|
+
}
|
|
987
|
+
function serializePrRulesYaml(rules) {
|
|
988
|
+
if (rules.length === 0) {
|
|
989
|
+
return '# No rules configured yet. Add entries under `rules:` below.\nrules: []\n';
|
|
990
|
+
}
|
|
991
|
+
const lines = ['rules:'];
|
|
992
|
+
for (const rule of rules) {
|
|
993
|
+
lines.push(` - id: ${yamlScalar(rule.id)}`);
|
|
994
|
+
lines.push(` name: ${yamlScalar(rule.name)}`);
|
|
995
|
+
lines.push(` type: ${rule.type}`);
|
|
996
|
+
lines.push(` severity: ${rule.severity}`);
|
|
997
|
+
if (rule.category)
|
|
998
|
+
lines.push(` category: ${yamlScalar(rule.category)}`);
|
|
999
|
+
lines.push(` message: ${yamlScalar(rule.message)}`);
|
|
1000
|
+
if (rule.llm) {
|
|
1001
|
+
lines.push(` llm:`);
|
|
1002
|
+
lines.push(` prompt: ${yamlBlockOrScalar(rule.llm.prompt, 8)}`);
|
|
1003
|
+
if (rule.llm.files)
|
|
1004
|
+
lines.push(` files: ${yamlScalar(rule.llm.files)}`);
|
|
1005
|
+
}
|
|
1006
|
+
if (rule.pattern) {
|
|
1007
|
+
lines.push(` pattern:`);
|
|
1008
|
+
lines.push(` regex: ${yamlScalar(rule.pattern.regex)}`);
|
|
1009
|
+
if (rule.pattern.files)
|
|
1010
|
+
lines.push(` files: ${yamlScalar(rule.pattern.files)}`);
|
|
1011
|
+
if (rule.pattern.exclude)
|
|
1012
|
+
lines.push(` exclude: ${yamlScalar(rule.pattern.exclude)}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return lines.join('\n') + '\n';
|
|
1016
|
+
}
|
|
1017
|
+
/** Build the onboarding files to write for one repo from its (toggled) scan
|
|
1018
|
+
* result. `.haystack.json` is always written (it enables the merge queue); the
|
|
1019
|
+
* rules + policy files only when there's enabled content for them. */
|
|
1020
|
+
function buildOnboardingFiles(result, autoMerge) {
|
|
1021
|
+
const files = [];
|
|
1022
|
+
const prRules = buildPrRules(result.rules);
|
|
1023
|
+
if (prRules.length > 0) {
|
|
1024
|
+
files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules), message: RULES_COMMIT_MSG });
|
|
1025
|
+
}
|
|
1026
|
+
const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
|
|
1027
|
+
if (reviewPolicyMd) {
|
|
1028
|
+
files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd, message: POLICY_COMMIT_MSG });
|
|
1029
|
+
}
|
|
1030
|
+
files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge), message: CONFIG_COMMIT_MSG });
|
|
1031
|
+
return files;
|
|
1032
|
+
}
|
|
1033
|
+
async function stepConfirm(byRepo, token) {
|
|
1034
|
+
const selectedRepos = [...byRepo.keys()];
|
|
1035
|
+
console.log(chalk.bold('\n Step 4: Confirm & write configuration\n'));
|
|
1036
|
+
// Per-repo summary of what will be written.
|
|
1037
|
+
for (const [repo, result] of byRepo) {
|
|
1038
|
+
const enabledRules = result.rules.filter((r) => r.enabled).length;
|
|
1039
|
+
const enabledPolicies = result.policies.filter((p) => p.enabled).length;
|
|
1040
|
+
const enabledInstructions = result.instructions.filter((i) => i.enabled).length;
|
|
1041
|
+
const parts = [];
|
|
1042
|
+
if (enabledRules > 0)
|
|
1043
|
+
parts.push(`${enabledRules} rule(s) → ${PR_RULES_PATH}`);
|
|
1044
|
+
if (enabledPolicies + enabledInstructions > 0) {
|
|
1045
|
+
parts.push(`${enabledPolicies + enabledInstructions} policy/instruction(s) → ${REVIEW_POLICY_PATH}`);
|
|
1046
|
+
}
|
|
1047
|
+
parts.push(`merge queue → ${HAYSTACK_CONFIG_PATH}`);
|
|
1048
|
+
console.log(` ${chalk.bold(repo)}`);
|
|
1049
|
+
parts.forEach((p) => console.log(` ${chalk.dim('•')} ${chalk.dim(p)}`));
|
|
1050
|
+
}
|
|
1051
|
+
console.log('');
|
|
1052
|
+
// Auto-merge is the master switch. Default ON (matches the web wizard default
|
|
1053
|
+
// + the repo-level preferences.auto_merge opt-in model). When off, the
|
|
1054
|
+
// bootstrap PR is opened but NOT labeled, so the user merges it manually and
|
|
1055
|
+
// no PRs auto-merge.
|
|
1056
|
+
const { autoMerge } = await inquirer.prompt([
|
|
1057
|
+
{
|
|
1058
|
+
type: 'confirm',
|
|
1059
|
+
name: 'autoMerge',
|
|
1060
|
+
message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
|
|
1061
|
+
default: true,
|
|
1062
|
+
},
|
|
1063
|
+
]);
|
|
917
1064
|
const { confirmed } = await inquirer.prompt([
|
|
918
1065
|
{
|
|
919
1066
|
type: 'confirm',
|
|
920
1067
|
name: 'confirmed',
|
|
921
|
-
message: `Write
|
|
1068
|
+
message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
|
|
922
1069
|
default: true,
|
|
923
1070
|
},
|
|
924
1071
|
]);
|
|
@@ -926,24 +1073,27 @@ async function stepConfirm(selectedRepos, rules, signals, policies, token) {
|
|
|
926
1073
|
console.log(chalk.yellow('\n Setup cancelled.\n'));
|
|
927
1074
|
return { confirmed: false, bootstrapPRs: new Map() };
|
|
928
1075
|
}
|
|
929
|
-
// Write to each repo
|
|
930
1076
|
const failedRepos = [];
|
|
931
1077
|
const bootstrapPRs = new Map();
|
|
932
1078
|
for (const repoFullName of selectedRepos) {
|
|
933
1079
|
const [owner, repo] = repoFullName.split('/');
|
|
1080
|
+
const result = byRepo.get(repoFullName);
|
|
1081
|
+
const files = buildOnboardingFiles(result, autoMerge);
|
|
934
1082
|
try {
|
|
935
1083
|
process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
|
|
936
|
-
const
|
|
937
|
-
const mergedConfig = existing ? deepMerge(existing.config, config) : config;
|
|
938
|
-
const result = await writeConfigToRepo(owner, repo, mergedConfig, existing?.sha ?? null, token);
|
|
1084
|
+
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
|
|
939
1085
|
process.stdout.write('\r\x1b[K');
|
|
940
|
-
if (
|
|
941
|
-
// Default branch was protected — config landed in a bootstrap PR
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1086
|
+
if (writeResult.bootstrapPR) {
|
|
1087
|
+
// Default branch was protected — config landed in a bootstrap PR. With
|
|
1088
|
+
// auto-merge on, Haystack merges it once its labels are processed; with
|
|
1089
|
+
// auto-merge off it waits for the user. Step 7 ensures
|
|
1090
|
+
// .entire/settings.json is on the same PR.
|
|
1091
|
+
bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
|
|
1092
|
+
const note = autoMerge
|
|
1093
|
+
? ' (default branch protected — opened a PR)'
|
|
1094
|
+
: ' (default branch protected — opened a PR for you to review and merge)';
|
|
1095
|
+
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
|
|
1096
|
+
console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
|
|
947
1097
|
}
|
|
948
1098
|
else {
|
|
949
1099
|
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
@@ -996,14 +1146,12 @@ export async function setupCommand() {
|
|
|
996
1146
|
await stepEnsureAppInstalled(token);
|
|
997
1147
|
// Step 1: Select repos
|
|
998
1148
|
const selectedRepos = await stepSelectRepos(token);
|
|
999
|
-
//
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
// Step
|
|
1004
|
-
const
|
|
1005
|
-
// Step 6: Confirm & write
|
|
1006
|
-
const { confirmed, bootstrapPRs } = await stepConfirm(selectedRepos, reviewed.rules, reviewed.signals, reviewed.policies, token);
|
|
1149
|
+
// Step 2: Unified scan per repo (rules + policies + instructions)
|
|
1150
|
+
const byRepo = await stepScanRepos(selectedRepos, token);
|
|
1151
|
+
// Step 3: Review (toggle items off)
|
|
1152
|
+
await stepReview(byRepo);
|
|
1153
|
+
// Step 4: Confirm & write the config files
|
|
1154
|
+
const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
|
|
1007
1155
|
if (!confirmed)
|
|
1008
1156
|
return;
|
|
1009
1157
|
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
|
|
@@ -1125,7 +1273,7 @@ async function configureEntireSettingsViaAPI(repoFullName, token) {
|
|
|
1125
1273
|
};
|
|
1126
1274
|
if (existingSha)
|
|
1127
1275
|
body.sha = existingSha;
|
|
1128
|
-
const putResp = await fetch(`${
|
|
1276
|
+
const putResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
|
|
1129
1277
|
method: 'PUT',
|
|
1130
1278
|
headers,
|
|
1131
1279
|
body: JSON.stringify(body),
|
|
@@ -1204,11 +1352,12 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1204
1352
|
bundled++;
|
|
1205
1353
|
}
|
|
1206
1354
|
else {
|
|
1207
|
-
//
|
|
1208
|
-
// optional
|
|
1209
|
-
// configured; the warning was already shown
|
|
1355
|
+
// The bootstrap PR shipped the config files but not
|
|
1356
|
+
// .entire/settings.json — its optional read failed transiently. Don't
|
|
1357
|
+
// count it as configured; the warning was already shown when the PR
|
|
1358
|
+
// was opened.
|
|
1210
1359
|
console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
|
|
1211
|
-
chalk.dim(' — bootstrap PR has
|
|
1360
|
+
chalk.dim(' — bootstrap PR has config files only; re-run setup to add session tracking'));
|
|
1212
1361
|
}
|
|
1213
1362
|
continue;
|
|
1214
1363
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* npx @haystackeditor/cli pr-status 123 # Show PR status in Haystack pipeline
|
|
20
20
|
* npx @haystackeditor/cli config # Manage preferences
|
|
21
21
|
*/
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
22
25
|
import { Command } from 'commander';
|
|
23
26
|
import { statusCommand } from './commands/status.js';
|
|
24
27
|
import { initCommand } from './commands/init.js';
|
|
@@ -34,11 +37,23 @@ import { dismissCommand, markReviewedCommand, undismissCommand } from './command
|
|
|
34
37
|
import { requestReviewCommand } from './commands/request-review.js';
|
|
35
38
|
import { prStatusCommand } from './commands/pr-status.js';
|
|
36
39
|
import { setupCommand } from './commands/setup.js';
|
|
40
|
+
/** Read the published version from package.json (dist/index.js → ../package.json)
|
|
41
|
+
* so `haystack --version` can't drift from the package version. Falls back
|
|
42
|
+
* gracefully so the flag never throws if the file can't be read. */
|
|
43
|
+
function getVersion() {
|
|
44
|
+
try {
|
|
45
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
46
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '0.0.0';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return '0.0.0';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
37
52
|
const program = new Command();
|
|
38
53
|
program
|
|
39
54
|
.name('haystack')
|
|
40
55
|
.description('Haystack CLI — automated PR review, triage, and merge queue')
|
|
41
|
-
.version(
|
|
56
|
+
.version(getVersion());
|
|
42
57
|
program
|
|
43
58
|
.command('init')
|
|
44
59
|
.description('Create .haystack.json configuration')
|
package/dist/utils/github-api.js
CHANGED
|
@@ -4,7 +4,27 @@
|
|
|
4
4
|
* Provides functions for creating PRs, managing labels, etc.
|
|
5
5
|
*/
|
|
6
6
|
import { resolveAuthContext } from './auth.js';
|
|
7
|
+
import { trackError } from './telemetry.js';
|
|
7
8
|
const GITHUB_API_BASE = 'https://api.github.com';
|
|
9
|
+
// OAuth App Policy fallback. Orgs can restrict our OAuth app, which makes the
|
|
10
|
+
// user's token 403 on every API call to that org — so `haystack submit` can't
|
|
11
|
+
// open PRs, add labels, or request reviewers there. The auth-worker GitHub
|
|
12
|
+
// proxy swaps in the GitHub App installation token server-side (the App isn't
|
|
13
|
+
// subject to OAuth App Policy / SAML SSO enforcement the way the OAuth token
|
|
14
|
+
// is), so on such a 403 we retry the same request through it. Direct calls are
|
|
15
|
+
// tried first, so on unrestricted orgs nothing changes and PRs stay
|
|
16
|
+
// user-authored; only the fallback path is performed by the App (haystack[bot]).
|
|
17
|
+
const GITHUB_PROXY_BASE = 'https://haystackeditor.com/api/github';
|
|
18
|
+
/**
|
|
19
|
+
* A 403 that is NOT a rate-limit is almost always OAuth App Policy or SAML SSO
|
|
20
|
+
* enforcement blocking our OAuth app on this org — exactly what the
|
|
21
|
+
* installation-token proxy can bypass. Rate-limit 403s carry
|
|
22
|
+
* `x-ratelimit-remaining: 0` and the proxy can't help with those, so we don't
|
|
23
|
+
* retry them.
|
|
24
|
+
*/
|
|
25
|
+
function isOAuthPolicyForbidden(response) {
|
|
26
|
+
return response.status === 403 && response.headers.get('x-ratelimit-remaining') !== '0';
|
|
27
|
+
}
|
|
8
28
|
// Label definitions for Haystack workflow
|
|
9
29
|
export const HAYSTACK_LABELS = {
|
|
10
30
|
'haystack:auto-merge': {
|
|
@@ -43,8 +63,9 @@ async function resolveToken(token, options) {
|
|
|
43
63
|
* Make an authenticated GitHub API request
|
|
44
64
|
*/
|
|
45
65
|
async function githubApiRequest(method, path, token, body) {
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
// body is serialized to a string (not a stream), so the same init can be
|
|
67
|
+
// reused for the proxy retry below.
|
|
68
|
+
const init = {
|
|
48
69
|
method,
|
|
49
70
|
headers: {
|
|
50
71
|
Authorization: `Bearer ${token}`,
|
|
@@ -53,8 +74,17 @@ async function githubApiRequest(method, path, token, body) {
|
|
|
53
74
|
'User-Agent': 'Haystack-CLI',
|
|
54
75
|
},
|
|
55
76
|
body: body ? JSON.stringify(body) : undefined,
|
|
56
|
-
}
|
|
57
|
-
|
|
77
|
+
};
|
|
78
|
+
const direct = await fetch(`${GITHUB_API_BASE}${path}`, init);
|
|
79
|
+
if (!isOAuthPolicyForbidden(direct))
|
|
80
|
+
return direct;
|
|
81
|
+
// Org restricts our OAuth app — retry through the proxy on the App's
|
|
82
|
+
// installation token. Same `Authorization: Bearer <github-token>`; the proxy
|
|
83
|
+
// validates it, then uses its own token upstream. If the proxy can't help
|
|
84
|
+
// (e.g. the App isn't installed on this owner), fall back to the original
|
|
85
|
+
// response so handleApiError surfaces GitHub's actual "org restricted" 403.
|
|
86
|
+
const proxied = await fetch(`${GITHUB_PROXY_BASE}${path}`, init);
|
|
87
|
+
return proxied.ok ? proxied : direct;
|
|
58
88
|
}
|
|
59
89
|
/**
|
|
60
90
|
* Handle API errors consistently
|
|
@@ -66,6 +96,16 @@ async function handleApiError(response, context) {
|
|
|
66
96
|
if (response.status === 403) {
|
|
67
97
|
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
|
|
68
98
|
if (rateLimitRemaining === '0') {
|
|
99
|
+
// Log every rate-limit hit so we can watch real-world frequency and decide
|
|
100
|
+
// whether the CLI needs its own token bucket. Routes to PostHog (and on to
|
|
101
|
+
// Slack via the ops-alert pipeline). Fire-and-forget; never blocks.
|
|
102
|
+
trackError('cli_github_rate_limited', {
|
|
103
|
+
context,
|
|
104
|
+
status: 403,
|
|
105
|
+
rate_limit_limit: response.headers.get('x-ratelimit-limit'),
|
|
106
|
+
rate_limit_reset: response.headers.get('x-ratelimit-reset'),
|
|
107
|
+
rate_limit_resource: response.headers.get('x-ratelimit-resource'),
|
|
108
|
+
});
|
|
69
109
|
throw new Error('GitHub API rate limit exceeded. Please wait and try again.');
|
|
70
110
|
}
|
|
71
111
|
throw new Error(`Permission denied: ${context}`);
|