@haystackeditor/cli 0.13.4 → 0.14.1
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/config.js +43 -0
- package/dist/commands/setup.js +565 -75
- package/dist/index.js +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
package/dist/commands/config.js
CHANGED
|
@@ -201,6 +201,46 @@ export async function isAutoMergeEnabled() {
|
|
|
201
201
|
return false;
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
/** Read the two queue-side toggles that depend on auto-merge. We re-read the
|
|
205
|
+
* file rather than calling getPreference() because those settings live under
|
|
206
|
+
* `merge_queue.*` instead of `preferences.*`. Returns null fields when the
|
|
207
|
+
* config is missing or can't be parsed — callers treat null as "not set".
|
|
208
|
+
* Note: loadHaystackConfig() process.exit(1)s on ENOENT, so this catch only
|
|
209
|
+
* fires for unexpected failures (parse errors, permission denied). We use the
|
|
210
|
+
* same logging helper as the other preference readers so failures surface in
|
|
211
|
+
* stderr instead of silently rolling back to defaults. */
|
|
212
|
+
function readDependentToggles() {
|
|
213
|
+
try {
|
|
214
|
+
const { config } = loadHaystackConfig();
|
|
215
|
+
return {
|
|
216
|
+
autoResolveConflicts: config.merge_queue?.auto_resolve_conflicts ?? null,
|
|
217
|
+
autoFixCiFailures: config.merge_queue?.auto_fix_ci_failures ?? null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
logPreferenceLookupFailure('merge_queue dependent toggles', err);
|
|
222
|
+
return { autoResolveConflicts: null, autoFixCiFailures: null };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function printDependencyNote(autoMergeEnabled) {
|
|
226
|
+
const deps = readDependentToggles();
|
|
227
|
+
// Only mention dependents that are explicitly configured — silent for repos
|
|
228
|
+
// that never set them. Both rely on auto-merge because they're merge-queue
|
|
229
|
+
// features that only fire on enqueued PRs.
|
|
230
|
+
const items = [];
|
|
231
|
+
if (deps.autoResolveConflicts != null)
|
|
232
|
+
items.push(`auto-resolve-conflicts (${deps.autoResolveConflicts ? 'on' : 'off'})`);
|
|
233
|
+
if (deps.autoFixCiFailures != null)
|
|
234
|
+
items.push(`auto-fix-ci-failures (${deps.autoFixCiFailures ? 'on' : 'off'})`);
|
|
235
|
+
if (items.length === 0)
|
|
236
|
+
return;
|
|
237
|
+
if (autoMergeEnabled) {
|
|
238
|
+
console.log(chalk.dim(`Tied to auto-merge: ${items.join(', ')}.`));
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
console.log(chalk.yellow(`Note: ${items.join(', ')} won't run while auto-merge is off — these are merge-queue features that only fire on enqueued PRs.`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
204
244
|
export async function getAutoMergeStatus() {
|
|
205
245
|
const enabled = getPreference('auto_merge');
|
|
206
246
|
console.log(chalk.bold('\nAuto-merge:'), enabled
|
|
@@ -215,6 +255,7 @@ export async function getAutoMergeStatus() {
|
|
|
215
255
|
console.log(chalk.dim('Auto-merge is disabled. PRs require manual merge.'));
|
|
216
256
|
console.log(chalk.dim('Enable with: haystack config auto-merge on'));
|
|
217
257
|
}
|
|
258
|
+
printDependencyNote(enabled);
|
|
218
259
|
console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
|
|
219
260
|
console.log();
|
|
220
261
|
}
|
|
@@ -223,6 +264,7 @@ export async function enableAutoMerge() {
|
|
|
223
264
|
console.log(chalk.green('\nAuto-merge enabled.\n'));
|
|
224
265
|
console.log(chalk.dim('PRs submitted via `haystack submit` will be auto-merged'));
|
|
225
266
|
console.log(chalk.dim('when analysis finds no issues (safe to merge).'));
|
|
267
|
+
printDependencyNote(true);
|
|
226
268
|
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
227
269
|
}
|
|
228
270
|
export async function disableAutoMerge() {
|
|
@@ -230,6 +272,7 @@ export async function disableAutoMerge() {
|
|
|
230
272
|
console.log(chalk.yellow('\nAuto-merge disabled.\n'));
|
|
231
273
|
console.log(chalk.dim('PRs will require manual merge after analysis.'));
|
|
232
274
|
console.log(chalk.dim('Re-enable with: haystack config auto-merge on'));
|
|
275
|
+
printDependencyNote(false);
|
|
233
276
|
console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
|
|
234
277
|
}
|
|
235
278
|
export async function handleAutoMerge(action) {
|
package/dist/commands/setup.js
CHANGED
|
@@ -92,28 +92,355 @@ async function fetchExistingConfig(owner, repo, token) {
|
|
|
92
92
|
throw new Error('existing .haystack.json is malformed JSON — delete or fix it manually');
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
// Bootstrap PR constants — kept in sync with the web wizard
|
|
96
|
+
// (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
|
|
97
|
+
// the two labels are load-bearing: the worker's bootstrap merge path
|
|
98
|
+
// (agent/cloudflare/src/merge-queue-cron.ts) keys off them.
|
|
99
|
+
const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
|
|
100
|
+
const AUTO_MERGE_LABEL = 'haystack:auto-merge';
|
|
101
|
+
const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
|
|
102
|
+
const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
|
|
103
|
+
const BOOTSTRAP_PR_BODY = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
|
|
104
|
+
'It adds the Haystack onboarding config:\n' +
|
|
105
|
+
'- `.haystack.json` — enables the Haystack merge queue and review automation\n' +
|
|
106
|
+
'- `.entire/settings.json` — enables AI-session trace capture (powers intent-drift triage)\n\n' +
|
|
107
|
+
'Both files are committed to this branch; `.entire/settings.json` lands in a follow-up commit ' +
|
|
108
|
+
'during setup step 7, so it may not be present the instant the PR opens.\n\n' +
|
|
109
|
+
'Haystack will bootstrap-merge this PR automatically once the labels are applied. ' +
|
|
110
|
+
'You can tweak the files from GitHub or re-run `haystack setup` anytime.';
|
|
111
|
+
/**
|
|
112
|
+
* Encode a branch name for use in a URL path while preserving `/`
|
|
113
|
+
* separators (e.g. `release/stable`). encodeURIComponent on the whole
|
|
114
|
+
* string would turn `/` into `%2F`, which several GitHub endpoints reject.
|
|
115
|
+
*/
|
|
116
|
+
function encodeBranchPath(branch) {
|
|
117
|
+
return branch.split('/').map(encodeURIComponent).join('/');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Is the given branch protected such that a direct commit can't land —
|
|
121
|
+
* either by legacy branch protection OR a repository ruleset?
|
|
122
|
+
*
|
|
123
|
+
* Decided from GitHub's *structured* signals, not error-message text:
|
|
124
|
+
* - `GET /repos/{o}/{r}/branches/{b}` → `.protected` (legacy protection)
|
|
125
|
+
* - `GET /repos/{o}/{r}/rules/branches/{b}` → a `pull_request` rule means
|
|
126
|
+
* "changes must go through a PR" (rulesets)
|
|
127
|
+
*
|
|
128
|
+
* Called on the write-failure path: a 409/422 from the direct commit tells
|
|
129
|
+
* us *that* something rejected the write; this tells us *whether* it was
|
|
130
|
+
* protection (→ open a bootstrap PR) vs. a genuine bad request (→ surface
|
|
131
|
+
* the error). If the probe itself can't be completed we throw — guessing
|
|
132
|
+
* from message wording is exactly the brittleness we're avoiding here.
|
|
133
|
+
*/
|
|
134
|
+
async function isBranchProtected(owner, repo, branch, token) {
|
|
135
|
+
const headers = ghHeaders(token);
|
|
136
|
+
const enc = encodeBranchPath(branch);
|
|
137
|
+
// Legacy branch protection — the `protected` boolean is authoritative.
|
|
138
|
+
const branchRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/branches/${enc}`, { headers });
|
|
139
|
+
if (branchRes.ok) {
|
|
140
|
+
const data = (await branchRes.json());
|
|
141
|
+
if (data.protected === true)
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
else if (branchRes.status !== 404) {
|
|
145
|
+
throw new Error(`Couldn't check branch protection: ${branchRes.status} — ${await branchRes.text()}`);
|
|
146
|
+
}
|
|
147
|
+
// Repository rulesets — `rules/branches` collapses every active rule on
|
|
148
|
+
// the branch into one list. A `pull_request` rule = "must merge via PR".
|
|
149
|
+
const rulesRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
|
|
150
|
+
if (rulesRes.ok) {
|
|
151
|
+
const rules = (await rulesRes.json());
|
|
152
|
+
if (rules.some((r) => r.type === 'pull_request'))
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
else if (rulesRes.status !== 404) {
|
|
156
|
+
throw new Error(`Couldn't check repository rulesets: ${rulesRes.status} — ${await rulesRes.text()}`);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function ghHeaders(token) {
|
|
161
|
+
return {
|
|
162
|
+
Authorization: `Bearer ${token}`,
|
|
163
|
+
Accept: 'application/vnd.github.v3+json',
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
'User-Agent': 'Haystack-CLI',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async function getDefaultBranch(owner, repo, token) {
|
|
169
|
+
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
throw new Error(`Failed to read repo metadata: ${res.status} — ${await res.text()}`);
|
|
172
|
+
}
|
|
173
|
+
return (await res.json()).default_branch;
|
|
174
|
+
}
|
|
175
|
+
async function getBranchSha(owner, repo, branch, token) {
|
|
176
|
+
// GitHub's git/ref/heads/{ref} endpoint expects literal slashes in the ref
|
|
177
|
+
// path — encodeURIComponent would turn `release/stable` into
|
|
178
|
+
// `release%2Fstable` and 404. encodeBranchPath escapes each segment but
|
|
179
|
+
// keeps the `/` separators.
|
|
180
|
+
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${encodeBranchPath(branch)}`, { headers: ghHeaders(token) });
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
throw new Error(`Failed to read branch ref: ${res.status} — ${await res.text()}`);
|
|
183
|
+
}
|
|
184
|
+
return (await res.json()).object.sha;
|
|
185
|
+
}
|
|
186
|
+
async function createBranch(owner, repo, branch, sha, token) {
|
|
187
|
+
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: ghHeaders(token),
|
|
190
|
+
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
throw new Error(`Failed to create branch: ${res.status} — ${await res.text()}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function createBootstrapPR(owner, repo, head, base, token) {
|
|
197
|
+
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/pulls`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: ghHeaders(token),
|
|
200
|
+
body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body: BOOTSTRAP_PR_BODY }),
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
throw new Error(`Failed to open PR: ${res.status} — ${await res.text()}`);
|
|
204
|
+
}
|
|
205
|
+
return (await res.json());
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Build a single commit containing `files` on top of `baseSha` via the Git
|
|
209
|
+
* Data API (blobs → tree → commit). Returns the new commit sha.
|
|
210
|
+
*
|
|
211
|
+
* Why not `PUT /contents/`: that updates a ref, and a ruleset whose
|
|
212
|
+
* `pull_request` rule targets `~ALL` branches blocks ref *updates* on
|
|
213
|
+
* EVERY branch — including a fresh onboarding branch. So
|
|
214
|
+
* createBranch-then-commitFileToBranch fails at the commit step. Building
|
|
215
|
+
* the commit as a standalone object and then *creating* a ref at it only
|
|
216
|
+
* ever creates a ref, never updates one (and there is no `creation`
|
|
217
|
+
* restriction in those rulesets), so it gets through. Also works fine for
|
|
218
|
+
* normally-protected repos. Critically: on a `~ALL` repo you cannot add a
|
|
219
|
+
* second commit afterward either, so BOTH onboarding files must land in
|
|
220
|
+
* this one commit.
|
|
221
|
+
*/
|
|
222
|
+
async function createCommitWithFiles(owner, repo, baseSha, files, message, token) {
|
|
223
|
+
const headers = ghHeaders(token);
|
|
224
|
+
// The new tree extends the base commit's tree.
|
|
225
|
+
const baseCommitRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/commits/${baseSha}`, { headers });
|
|
226
|
+
if (!baseCommitRes.ok) {
|
|
227
|
+
throw new Error(`Failed to read base commit: ${baseCommitRes.status} — ${await baseCommitRes.text()}`);
|
|
228
|
+
}
|
|
229
|
+
const baseTreeSha = (await baseCommitRes.json()).tree.sha;
|
|
230
|
+
// One blob per file.
|
|
231
|
+
const treeEntries = await Promise.all(files.map(async (f) => {
|
|
232
|
+
const blobRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/blobs`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers,
|
|
235
|
+
body: JSON.stringify({ content: f.content, encoding: 'utf-8' }),
|
|
236
|
+
});
|
|
237
|
+
if (!blobRes.ok) {
|
|
238
|
+
throw new Error(`Failed to create blob for ${f.path}: ${blobRes.status} — ${await blobRes.text()}`);
|
|
239
|
+
}
|
|
240
|
+
const blob = (await blobRes.json());
|
|
241
|
+
return { path: f.path, mode: '100644', type: 'blob', sha: blob.sha };
|
|
242
|
+
}));
|
|
243
|
+
const treeRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/trees`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers,
|
|
246
|
+
body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
|
|
247
|
+
});
|
|
248
|
+
if (!treeRes.ok) {
|
|
249
|
+
throw new Error(`Failed to create tree: ${treeRes.status} — ${await treeRes.text()}`);
|
|
250
|
+
}
|
|
251
|
+
const tree = (await treeRes.json());
|
|
252
|
+
const commitRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/commits`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers,
|
|
255
|
+
body: JSON.stringify({ message, tree: tree.sha, parents: [baseSha] }),
|
|
256
|
+
});
|
|
257
|
+
if (!commitRes.ok) {
|
|
258
|
+
throw new Error(`Failed to create commit: ${commitRes.status} — ${await commitRes.text()}`);
|
|
259
|
+
}
|
|
260
|
+
return (await commitRes.json()).sha;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Compute the desired `.entire/settings.json` content for a repo: fetch the
|
|
264
|
+
* existing file (if any), strip the legacy `skip_raw_transcript` option, and
|
|
265
|
+
* serialize. Returns `null` when the file already exists and is unchanged
|
|
266
|
+
* (nothing to write). Otherwise returns the content AND the existing blob
|
|
267
|
+
* sha (or null if the file doesn't exist) — so the direct-write path can
|
|
268
|
+
* issue a correct update PUT without a second redundant GET. Pure read +
|
|
269
|
+
* transform — does no writes itself, so it can feed either the bootstrap-PR
|
|
270
|
+
* commit or the direct-write path.
|
|
271
|
+
*
|
|
272
|
+
* Non-404 read failures throw — they must NOT be silently coerced to
|
|
273
|
+
* "file doesn't exist", which would make the subsequent PUT a create and
|
|
274
|
+
* GitHub reject it 422, masking the real transient error.
|
|
275
|
+
*/
|
|
276
|
+
async function buildEntireSettingsContent(repoFullName, token) {
|
|
277
|
+
const [owner, repo] = repoFullName.split('/');
|
|
278
|
+
const headers = ghHeaders(token);
|
|
279
|
+
let settings = { enabled: true };
|
|
280
|
+
let originalContent = '';
|
|
281
|
+
let exists = false;
|
|
282
|
+
let existingSha = null;
|
|
283
|
+
const getResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.entire/settings.json`, { headers });
|
|
284
|
+
if (getResp.ok) {
|
|
285
|
+
exists = true;
|
|
286
|
+
const data = (await getResp.json());
|
|
287
|
+
existingSha = data.sha;
|
|
288
|
+
originalContent = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
289
|
+
try {
|
|
290
|
+
settings = JSON.parse(originalContent);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
console.log(chalk.dim(` Existing .entire/settings.json in ${repoFullName} is malformed — will repair`));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else if (getResp.status !== 404) {
|
|
297
|
+
const errText = await getResp.text().catch(() => '');
|
|
298
|
+
const error = new Error(`Failed to read .entire/settings.json: ${getResp.status} ${errText}`);
|
|
299
|
+
trackError('entire_settings_read_failed', {
|
|
300
|
+
error_message: error.message,
|
|
301
|
+
repo: repoFullName,
|
|
302
|
+
status: getResp.status,
|
|
303
|
+
});
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
// Remove legacy skip_raw_transcript if present (v0.5.3-haystack.2+ compacts automatically)
|
|
307
|
+
if (settings.strategy_options && typeof settings.strategy_options === 'object') {
|
|
308
|
+
const strategyOptions = settings.strategy_options;
|
|
309
|
+
delete strategyOptions.skip_raw_transcript;
|
|
310
|
+
if (Object.keys(strategyOptions).length === 0) {
|
|
311
|
+
delete settings.strategy_options;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const updatedContent = JSON.stringify(settings, null, 2) + '\n';
|
|
315
|
+
// Nothing to do if the file already exists unchanged.
|
|
316
|
+
if (exists && updatedContent === originalContent)
|
|
317
|
+
return null;
|
|
318
|
+
return { content: updatedContent, existingSha };
|
|
319
|
+
}
|
|
320
|
+
async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
|
|
321
|
+
// CRITICAL, not best-effort: without BOTH labels the worker's bootstrap
|
|
322
|
+
// merge path never discovers the PR and it sits open forever. The label
|
|
323
|
+
// API failing is almost always transient, so retry a few times before
|
|
324
|
+
// giving up.
|
|
325
|
+
const labels = [AUTO_MERGE_LABEL, ONBOARDING_BOOTSTRAP_LABEL];
|
|
326
|
+
let lastErr = '';
|
|
327
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
328
|
+
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: ghHeaders(token),
|
|
331
|
+
body: JSON.stringify({ labels }),
|
|
332
|
+
});
|
|
333
|
+
if (res.ok)
|
|
334
|
+
return;
|
|
335
|
+
lastErr = `${res.status} — ${await res.text()}`;
|
|
336
|
+
if (attempt < 3) {
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 500));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Retries exhausted. The PR exists with the config committed — it just
|
|
341
|
+
// won't auto-merge without the labels. Throw an actionable error: name
|
|
342
|
+
// the PR, name the exact labels, and warn against re-running (which would
|
|
343
|
+
// open a duplicate PR on a fresh timestamped branch).
|
|
344
|
+
throw new Error(`opened PR ${prUrl} but couldn't apply the auto-merge labels after 3 tries (${lastErr}). ` +
|
|
345
|
+
`Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
|
|
346
|
+
`Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Protected-branch fallback: build ONE commit containing both onboarding
|
|
350
|
+
* files (`.haystack.json` + `.entire/settings.json`) via the Git Data API,
|
|
351
|
+
* CREATE a branch ref at it, open a PR, and apply the auto-merge + bootstrap
|
|
352
|
+
* labels.
|
|
353
|
+
*
|
|
354
|
+
* Why both files in one commit: a ruleset whose `pull_request` rule targets
|
|
355
|
+
* `~ALL` branches blocks ref *updates* on every branch — so you can neither
|
|
356
|
+
* 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 both
|
|
358
|
+
* bootstrap-critical files (`.haystack.json` enables the merge queue,
|
|
359
|
+
* `.entire/settings.json` enables the trace capture intent-drift triage
|
|
360
|
+
* runs on) must land in the single commit the branch is created at.
|
|
361
|
+
*/
|
|
362
|
+
async function openBootstrapConfigPR(owner, repo, configContent, token, defaultBranch) {
|
|
363
|
+
const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
|
|
364
|
+
// Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
|
|
365
|
+
// The timestamp avoids collisions when setup is re-run.
|
|
366
|
+
const branchName = `haystack/onboarding-${Date.now()}`;
|
|
367
|
+
const files = [
|
|
368
|
+
{ path: '.haystack.json', content: configContent },
|
|
369
|
+
];
|
|
370
|
+
// Bundle .entire/settings.json into the same commit — on a ~ALL ruleset
|
|
371
|
+
// we can't add it later. It's repo-level config (independent of whether
|
|
372
|
+
// this dev installs the Entire binary locally), so it rides along here.
|
|
373
|
+
// The Git Data tree API overwrites by path, so existingSha isn't needed here.
|
|
374
|
+
//
|
|
375
|
+
// Best-effort: .entire/settings.json is the OPTIONAL file; .haystack.json
|
|
376
|
+
// is the bootstrap-critical one. A transient non-404 read failure here
|
|
377
|
+
// must NOT abort the whole PR (buildEntireSettingsContent throws on
|
|
378
|
+
// non-404). Catch it, warn, and ship the PR with just .haystack.json —
|
|
379
|
+
// far better than failing setup over the optional file. The customer can
|
|
380
|
+
// re-run `haystack setup` to pick up .entire/settings.json once GitHub
|
|
381
|
+
// is healthy.
|
|
382
|
+
// entireSettings === null means the file already exists unchanged — there's
|
|
383
|
+
// nothing to bundle, but the repo IS correctly configured, so that still
|
|
384
|
+
// counts as bundled. Only a read *failure* (caught below) sets this false.
|
|
385
|
+
let entireSettingsBundled = true;
|
|
386
|
+
try {
|
|
387
|
+
const entireSettings = await buildEntireSettingsContent(`${owner}/${repo}`, token);
|
|
388
|
+
if (entireSettings !== null) {
|
|
389
|
+
files.push({ path: '.entire/settings.json', content: entireSettings.content });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
// buildEntireSettingsContent already tracked this with the real HTTP
|
|
394
|
+
// status before throwing — don't re-emit a duplicate event here. Just
|
|
395
|
+
// warn and ship the PR with .haystack.json only.
|
|
396
|
+
entireSettingsBundled = false;
|
|
397
|
+
console.log(chalk.yellow(` \u26a0 Couldn't read .entire/settings.json for ${owner}/${repo} (${err instanceof Error ? err.message : err}) — ` +
|
|
398
|
+
`opening the bootstrap PR with .haystack.json only. Re-run \`haystack setup\` to add session tracking.`));
|
|
399
|
+
}
|
|
400
|
+
const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
|
|
401
|
+
await createBranch(owner, repo, branchName, commitSha, token);
|
|
402
|
+
const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token);
|
|
403
|
+
await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
|
|
404
|
+
return { prUrl: pr.html_url, entireSettingsBundled };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Write `.haystack.json` to a repo. Tries a direct commit to the default
|
|
408
|
+
* branch first; on a protected-branch / ruleset error, falls back to opening
|
|
409
|
+
* a bootstrap PR (branch + commit + PR + labels) so setup still completes.
|
|
410
|
+
*
|
|
411
|
+
* Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
|
|
412
|
+
* thread the branch into step 7), `{}` on a clean direct commit.
|
|
413
|
+
*/
|
|
95
414
|
async function writeConfigToRepo(owner, repo, config, existingSha, token) {
|
|
96
415
|
const content = JSON.stringify(config, null, 2) + '\n';
|
|
97
416
|
const body = {
|
|
98
|
-
message:
|
|
417
|
+
message: CONFIG_COMMIT_MSG,
|
|
99
418
|
content: Buffer.from(content).toString('base64'),
|
|
100
419
|
};
|
|
101
420
|
if (existingSha)
|
|
102
421
|
body.sha = existingSha;
|
|
103
422
|
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
|
|
104
423
|
method: 'PUT',
|
|
105
|
-
headers:
|
|
106
|
-
Authorization: `Bearer ${token}`,
|
|
107
|
-
Accept: 'application/vnd.github.v3+json',
|
|
108
|
-
'Content-Type': 'application/json',
|
|
109
|
-
'User-Agent': 'Haystack-CLI',
|
|
110
|
-
},
|
|
424
|
+
headers: ghHeaders(token),
|
|
111
425
|
body: JSON.stringify(body),
|
|
112
426
|
});
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
427
|
+
if (response.ok)
|
|
428
|
+
return {};
|
|
429
|
+
const err = await response.text();
|
|
430
|
+
// A 409/422 means *something* rejected the direct commit. Probe GitHub's
|
|
431
|
+
// structured protection signals to find out whether it was branch
|
|
432
|
+
// protection / a ruleset (→ open a bootstrap PR) or a genuine bad request
|
|
433
|
+
// (→ surface the error). Other statuses (auth, 404, 5xx) are never a
|
|
434
|
+
// protected-branch situation, so don't bother probing.
|
|
435
|
+
if (response.status === 409 || response.status === 422) {
|
|
436
|
+
const defaultBranch = await getDefaultBranch(owner, repo, token);
|
|
437
|
+
if (await isBranchProtected(owner, repo, defaultBranch, token)) {
|
|
438
|
+
// Protected — open a bootstrap PR instead of failing.
|
|
439
|
+
const bootstrapPR = await openBootstrapConfigPR(owner, repo, content, token, defaultBranch);
|
|
440
|
+
return { bootstrapPR };
|
|
441
|
+
}
|
|
116
442
|
}
|
|
443
|
+
throw new Error(`Failed to write config: ${response.status} — ${err}`);
|
|
117
444
|
}
|
|
118
445
|
// =============================================================================
|
|
119
446
|
// Merge helpers
|
|
@@ -234,6 +561,163 @@ function printPolicy(policy, index) {
|
|
|
234
561
|
// =============================================================================
|
|
235
562
|
// Wizard steps
|
|
236
563
|
// =============================================================================
|
|
564
|
+
// =============================================================================
|
|
565
|
+
// Step 0.5: Ensure the Haystack GitHub App is installed
|
|
566
|
+
// =============================================================================
|
|
567
|
+
const HAYSTACK_APP_SLUG = 'haystack-code-reviewer-pr-hook';
|
|
568
|
+
const HAYSTACK_APP_INSTALL_URL = `https://github.com/apps/${HAYSTACK_APP_SLUG}/installations/new`;
|
|
569
|
+
const APP_INSTALL_POLL_INTERVAL_MS = 3000;
|
|
570
|
+
const APP_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
571
|
+
class InstallationsAuthError extends Error {
|
|
572
|
+
status;
|
|
573
|
+
constructor(status, message) {
|
|
574
|
+
super(message);
|
|
575
|
+
this.status = status;
|
|
576
|
+
this.name = 'InstallationsAuthError';
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function fetchHaystackInstallations(token) {
|
|
580
|
+
// /user/installations lists App installations the OAuth-authenticated user
|
|
581
|
+
// can manage. Returns 200 with `installations: []` when none exist.
|
|
582
|
+
const res = await fetch(`${GITHUB_API}/user/installations?per_page=100`, {
|
|
583
|
+
headers: {
|
|
584
|
+
Authorization: `Bearer ${token}`,
|
|
585
|
+
Accept: 'application/vnd.github.v3+json',
|
|
586
|
+
'User-Agent': 'Haystack-CLI',
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
if (!res.ok) {
|
|
590
|
+
// 401/403 are auth-bucket failures — token revoked, expired, or missing
|
|
591
|
+
// the right scopes. Retrying never recovers; bail with a distinct error
|
|
592
|
+
// type so the poll loop can short-circuit and prompt re-auth.
|
|
593
|
+
if (res.status === 401 || res.status === 403) {
|
|
594
|
+
throw new InstallationsAuthError(res.status, `${res.status} ${await res.text()}`);
|
|
595
|
+
}
|
|
596
|
+
throw new Error(`failed to fetch installations: ${res.status} ${await res.text()}`);
|
|
597
|
+
}
|
|
598
|
+
const data = (await res.json());
|
|
599
|
+
return (data.installations ?? []).filter((i) => i.app_slug === HAYSTACK_APP_SLUG);
|
|
600
|
+
}
|
|
601
|
+
function tryOpenBrowser(url) {
|
|
602
|
+
// Best-effort browser open. Never blocks setup — the URL is also printed.
|
|
603
|
+
// Windows: `start` is a cmd.exe builtin, not a standalone executable, so we
|
|
604
|
+
// can't invoke it via execFileSync directly. Route through cmd /c.
|
|
605
|
+
try {
|
|
606
|
+
if (process.platform === 'win32') {
|
|
607
|
+
// The empty quoted string is `start`'s "window title" arg — required
|
|
608
|
+
// when the first arg might be quoted, prevents start from misparsing
|
|
609
|
+
// the URL as a title. /c terminates cmd after the command runs.
|
|
610
|
+
execSync(`cmd /c start "" "${url.replace(/"/g, '%22')}"`, { stdio: 'ignore' });
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
614
|
+
execFileSync(opener, [url], { stdio: 'ignore' });
|
|
615
|
+
}
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
// Log so operators see systemic browser-open failures (corporate Linux
|
|
620
|
+
// without xdg-open, weird PATH, etc.). The user-visible UX is unchanged
|
|
621
|
+
// since the URL was already printed for manual copy.
|
|
622
|
+
console.log(chalk.dim(` (Couldn't auto-open browser: ${err.message})`));
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function stepEnsureAppInstalled(token) {
|
|
627
|
+
console.log(chalk.bold(' Step 0: Verify GitHub App'));
|
|
628
|
+
let installed;
|
|
629
|
+
try {
|
|
630
|
+
installed = await fetchHaystackInstallations(token);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
if (err instanceof InstallationsAuthError) {
|
|
634
|
+
// Token is dead — retrying won't help. Prompt re-auth and exit so the
|
|
635
|
+
// user doesn't waste a 10-minute poll on something that will never work.
|
|
636
|
+
console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
|
|
637
|
+
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
638
|
+
trackError('haystack_setup_installations_auth_failure', { status: err.status });
|
|
639
|
+
process.exit(1);
|
|
640
|
+
}
|
|
641
|
+
console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
|
|
642
|
+
console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
|
|
643
|
+
// Probe failed but we proceed (refuse-to-fail UX so a transient blip can't
|
|
644
|
+
// brick setup). Still log so we can see if probes start failing
|
|
645
|
+
// systemically across users.
|
|
646
|
+
trackError('haystack_setup_installations_probe_failed', {
|
|
647
|
+
error_message: err.message,
|
|
648
|
+
});
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (installed.length > 0) {
|
|
652
|
+
const accounts = installed.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
653
|
+
console.log(chalk.green(` ✓ Haystack App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
|
|
657
|
+
console.log(chalk.dim(' Without it, Haystack can\'t analyze, triage, or merge your PRs.'));
|
|
658
|
+
console.log('');
|
|
659
|
+
console.log(` Install at: ${chalk.cyan(HAYSTACK_APP_INSTALL_URL)}`);
|
|
660
|
+
const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
|
|
661
|
+
if (opened) {
|
|
662
|
+
console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
|
|
663
|
+
}
|
|
664
|
+
console.log('');
|
|
665
|
+
console.log(chalk.dim(' Waiting for install to complete...'));
|
|
666
|
+
const startedAt = Date.now();
|
|
667
|
+
let consecutiveFailures = 0;
|
|
668
|
+
let lastError = null;
|
|
669
|
+
while (Date.now() - startedAt < APP_INSTALL_TIMEOUT_MS) {
|
|
670
|
+
await new Promise((resolve) => setTimeout(resolve, APP_INSTALL_POLL_INTERVAL_MS));
|
|
671
|
+
let current;
|
|
672
|
+
try {
|
|
673
|
+
current = await fetchHaystackInstallations(token);
|
|
674
|
+
consecutiveFailures = 0;
|
|
675
|
+
lastError = null;
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
// Auth errors during the poll loop mean the token went bad mid-flow
|
|
679
|
+
// (e.g. user revoked it from GitHub settings). Same response as the
|
|
680
|
+
// initial check — bail with re-auth guidance instead of grinding for
|
|
681
|
+
// 10 minutes against a permanent failure.
|
|
682
|
+
if (err instanceof InstallationsAuthError) {
|
|
683
|
+
console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
|
|
684
|
+
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
685
|
+
trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
// Per-tick failures are usually transient (token refresh race, brief
|
|
689
|
+
// GitHub API blip). Log the first one quietly so the user sees we're
|
|
690
|
+
// not silently spinning, but don't spam the terminal every 3s.
|
|
691
|
+
consecutiveFailures += 1;
|
|
692
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
693
|
+
if (consecutiveFailures === 1) {
|
|
694
|
+
console.log(chalk.dim(` (Transient error checking installations: ${lastError} — retrying...)`));
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (current.length > 0) {
|
|
699
|
+
const accounts = current.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
700
|
+
console.log(chalk.green(`\n ✓ App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// Timeout is a real failure — emit a structured telemetry event so we can
|
|
705
|
+
// route it to ops alerting (Slack via the existing trackError → PostHog
|
|
706
|
+
// pipeline). Include whether the polling itself was failing (lastError
|
|
707
|
+
// non-null after the final tick) so we can tell "user didn't click install"
|
|
708
|
+
// apart from "GitHub API was down."
|
|
709
|
+
trackError('haystack_setup_app_install_timeout', {
|
|
710
|
+
consecutive_failures: consecutiveFailures,
|
|
711
|
+
last_error: lastError,
|
|
712
|
+
timeout_ms: APP_INSTALL_TIMEOUT_MS,
|
|
713
|
+
});
|
|
714
|
+
console.log(chalk.yellow('\n Timed out waiting for App install.'));
|
|
715
|
+
if (lastError) {
|
|
716
|
+
console.log(chalk.dim(` Last error checking installations: ${lastError}`));
|
|
717
|
+
}
|
|
718
|
+
console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
237
721
|
async function stepSelectRepos(token) {
|
|
238
722
|
console.log(chalk.bold('\n Step 1: Select repositories\n'));
|
|
239
723
|
console.log(chalk.dim(' Fetching your repositories...'));
|
|
@@ -440,19 +924,30 @@ async function stepConfirm(selectedRepos, rules, signals, policies, token) {
|
|
|
440
924
|
]);
|
|
441
925
|
if (!confirmed) {
|
|
442
926
|
console.log(chalk.yellow('\n Setup cancelled.\n'));
|
|
443
|
-
return false;
|
|
927
|
+
return { confirmed: false, bootstrapPRs: new Map() };
|
|
444
928
|
}
|
|
445
929
|
// Write to each repo
|
|
446
930
|
const failedRepos = [];
|
|
931
|
+
const bootstrapPRs = new Map();
|
|
447
932
|
for (const repoFullName of selectedRepos) {
|
|
448
933
|
const [owner, repo] = repoFullName.split('/');
|
|
449
934
|
try {
|
|
450
935
|
process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
|
|
451
936
|
const existing = await fetchExistingConfig(owner, repo, token);
|
|
452
937
|
const mergedConfig = existing ? deepMerge(existing.config, config) : config;
|
|
453
|
-
await writeConfigToRepo(owner, repo, mergedConfig, existing?.sha ?? null, token);
|
|
938
|
+
const result = await writeConfigToRepo(owner, repo, mergedConfig, existing?.sha ?? null, token);
|
|
454
939
|
process.stdout.write('\r\x1b[K');
|
|
455
|
-
|
|
940
|
+
if (result.bootstrapPR) {
|
|
941
|
+
// Default branch was protected — config landed in a bootstrap PR
|
|
942
|
+
// that Haystack will auto-merge once its labels are processed.
|
|
943
|
+
// Step 7 adds .entire/settings.json to this same PR.
|
|
944
|
+
bootstrapPRs.set(repoFullName, result.bootstrapPR);
|
|
945
|
+
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (default branch protected — opened a PR)'));
|
|
946
|
+
console.log(chalk.dim(` ${result.bootstrapPR.prUrl}`));
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
950
|
+
}
|
|
456
951
|
}
|
|
457
952
|
catch (err) {
|
|
458
953
|
process.stdout.write('\r\x1b[K');
|
|
@@ -465,7 +960,7 @@ async function stepConfirm(selectedRepos, rules, signals, policies, token) {
|
|
|
465
960
|
process.exit(1);
|
|
466
961
|
}
|
|
467
962
|
console.log(chalk.green('\n Setup complete!\n'));
|
|
468
|
-
return true;
|
|
963
|
+
return { confirmed: true, bootstrapPRs };
|
|
469
964
|
}
|
|
470
965
|
// =============================================================================
|
|
471
966
|
// Main command
|
|
@@ -495,6 +990,10 @@ export async function setupCommand() {
|
|
|
495
990
|
}
|
|
496
991
|
const user = (await userResponse.json());
|
|
497
992
|
console.log(chalk.dim(` Logged in as ${chalk.bold(user.login)}\n`));
|
|
993
|
+
// Step 0.5: Ensure the Haystack GitHub App is installed. Without it, all
|
|
994
|
+
// the server-side machinery (merge queue, analysis, fixer, review chat) is
|
|
995
|
+
// dark — the user would write .haystack.json and then nothing would happen.
|
|
996
|
+
await stepEnsureAppInstalled(token);
|
|
498
997
|
// Step 1: Select repos
|
|
499
998
|
const selectedRepos = await stepSelectRepos(token);
|
|
500
999
|
// Steps 2-4: Scan for rules, wait-for signals, and review policies
|
|
@@ -504,11 +1003,13 @@ export async function setupCommand() {
|
|
|
504
1003
|
// Step 5: Review
|
|
505
1004
|
const reviewed = await stepReview(rules, signals, policies);
|
|
506
1005
|
// Step 6: Confirm & write
|
|
507
|
-
const confirmed = await stepConfirm(selectedRepos, reviewed.rules, reviewed.signals, reviewed.policies, token);
|
|
1006
|
+
const { confirmed, bootstrapPRs } = await stepConfirm(selectedRepos, reviewed.rules, reviewed.signals, reviewed.policies, token);
|
|
508
1007
|
if (!confirmed)
|
|
509
1008
|
return;
|
|
510
|
-
// Step 7: Install Entire CLI (session tracking)
|
|
511
|
-
|
|
1009
|
+
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
|
|
1010
|
+
// which repos had a protected default branch so it can commit
|
|
1011
|
+
// .entire/settings.json to the same PR instead of a doomed direct PUT.
|
|
1012
|
+
await stepInstallEntire(selectedRepos, token, bootstrapPRs);
|
|
512
1013
|
}
|
|
513
1014
|
// =============================================================================
|
|
514
1015
|
// Step 7: Install Entire CLI for session tracking
|
|
@@ -595,68 +1096,36 @@ async function installEntireBinary() {
|
|
|
595
1096
|
}
|
|
596
1097
|
}
|
|
597
1098
|
/**
|
|
598
|
-
* Write .entire/settings.json
|
|
599
|
-
*
|
|
1099
|
+
* Write .entire/settings.json directly to the default branch.
|
|
1100
|
+
*
|
|
1101
|
+
* This is the DIRECT path only. Repos whose default branch is protected
|
|
1102
|
+
* never reach here — step 6's openBootstrapConfigPR already bundled
|
|
1103
|
+
* .entire/settings.json into the bootstrap PR's single commit (on a ~ALL
|
|
1104
|
+
* ruleset you can't add it as a later commit, so it must ride along in the
|
|
1105
|
+
* branch-creation commit). stepInstallEntire skips this call for those
|
|
1106
|
+
* repos. So if we're here, step 6 proved the default branch accepts a
|
|
1107
|
+
* direct commit.
|
|
600
1108
|
*/
|
|
601
1109
|
async function configureEntireSettingsViaAPI(repoFullName, token) {
|
|
602
1110
|
const [owner, repo] = repoFullName.split('/');
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
Accept: 'application/vnd.github.v3+json',
|
|
607
|
-
'User-Agent': 'Haystack-CLI',
|
|
608
|
-
'Content-Type': 'application/json',
|
|
609
|
-
};
|
|
610
|
-
// Fetch existing .entire/settings.json (if any)
|
|
611
|
-
let existingSha = null;
|
|
612
|
-
let settings = { enabled: true };
|
|
613
|
-
let originalContent = '';
|
|
614
|
-
const getResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/${filePath}`, { headers });
|
|
615
|
-
if (getResp.ok) {
|
|
616
|
-
const data = (await getResp.json());
|
|
617
|
-
existingSha = data.sha;
|
|
618
|
-
originalContent = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
619
|
-
try {
|
|
620
|
-
settings = JSON.parse(originalContent);
|
|
621
|
-
}
|
|
622
|
-
catch {
|
|
623
|
-
// Malformed JSON — keep sha for update but use fresh defaults
|
|
624
|
-
console.log(chalk.dim(` Existing .entire/settings.json in ${repoFullName} is malformed — will repair`));
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
else if (getResp.status !== 404) {
|
|
628
|
-
// Non-404 failure (auth, network, rate limit) — don't guess, surface it
|
|
629
|
-
const errText = await getResp.text().catch(() => '');
|
|
630
|
-
const error = new Error(`Failed to read .entire/settings.json: ${getResp.status} ${errText}`);
|
|
631
|
-
trackError('entire_settings_read_failed', {
|
|
632
|
-
error_message: error.message,
|
|
633
|
-
repo: repoFullName,
|
|
634
|
-
status: getResp.status,
|
|
635
|
-
});
|
|
636
|
-
throw error;
|
|
637
|
-
}
|
|
638
|
-
// 404 → file doesn't exist, existingSha stays null → PUT will create
|
|
639
|
-
// Remove legacy skip_raw_transcript if present (v0.5.3-haystack.2+ compacts automatically)
|
|
640
|
-
if (settings.strategy_options && typeof settings.strategy_options === 'object') {
|
|
641
|
-
const strategyOptions = settings.strategy_options;
|
|
642
|
-
delete strategyOptions.skip_raw_transcript;
|
|
643
|
-
if (Object.keys(strategyOptions).length === 0) {
|
|
644
|
-
delete settings.strategy_options;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
const updatedContent = JSON.stringify(settings, null, 2) + '\n';
|
|
648
|
-
// Skip write if nothing changed (avoids no-op commits)
|
|
649
|
-
if (existingSha && updatedContent === originalContent) {
|
|
1111
|
+
const result = await buildEntireSettingsContent(repoFullName, token);
|
|
1112
|
+
// null → file already exists unchanged; nothing to write.
|
|
1113
|
+
if (result === null)
|
|
650
1114
|
return;
|
|
651
|
-
|
|
652
|
-
|
|
1115
|
+
// buildEntireSettingsContent already fetched the file — reuse its
|
|
1116
|
+
// content + existingSha. A second GET here would (a) be redundant and
|
|
1117
|
+
// (b) on a transient non-404 failure fall back to existingSha=null,
|
|
1118
|
+
// turning the PUT into a create that GitHub rejects 422 — masking the
|
|
1119
|
+
// real read error. (Non-404 read failures already threw upstream.)
|
|
1120
|
+
const { content, existingSha } = result;
|
|
1121
|
+
const headers = ghHeaders(token);
|
|
653
1122
|
const body = {
|
|
654
1123
|
message: 'chore: configure Entire CLI settings',
|
|
655
|
-
content,
|
|
1124
|
+
content: Buffer.from(content).toString('base64'),
|
|
656
1125
|
};
|
|
657
1126
|
if (existingSha)
|
|
658
1127
|
body.sha = existingSha;
|
|
659
|
-
const putResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents
|
|
1128
|
+
const putResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
|
|
660
1129
|
method: 'PUT',
|
|
661
1130
|
headers,
|
|
662
1131
|
body: JSON.stringify(body),
|
|
@@ -673,7 +1142,7 @@ async function configureEntireSettingsViaAPI(repoFullName, token) {
|
|
|
673
1142
|
throw error;
|
|
674
1143
|
}
|
|
675
1144
|
}
|
|
676
|
-
async function stepInstallEntire(selectedRepos, token) {
|
|
1145
|
+
async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
677
1146
|
console.log(chalk.bold('\n Step 7: Session tracking (Entire CLI)\n'));
|
|
678
1147
|
const status = isEntireInstalled();
|
|
679
1148
|
let binaryReady = status.installed && status.isHaystackFork;
|
|
@@ -718,10 +1187,31 @@ async function stepInstallEntire(selectedRepos, token) {
|
|
|
718
1187
|
console.log(chalk.dim(' Skipping session tracking.\n'));
|
|
719
1188
|
}
|
|
720
1189
|
// Configure .entire/settings.json on each repo via GitHub API
|
|
721
|
-
// (works even if binary install failed — settings are remote; skip if user declined)
|
|
1190
|
+
// (works even if binary install failed — settings are remote; skip if user declined).
|
|
1191
|
+
// Repos whose default branch was protected went the bootstrap-PR route in
|
|
1192
|
+
// step 6, which ALREADY bundled .entire/settings.json into that PR's
|
|
1193
|
+
// single commit (on a ~ALL ruleset it can't be added as a later commit).
|
|
1194
|
+
// So we skip those here and only direct-write the non-protected repos.
|
|
722
1195
|
if (userConsented) {
|
|
723
1196
|
let configured = 0;
|
|
1197
|
+
let bundled = 0;
|
|
724
1198
|
for (const repoFullName of selectedRepos) {
|
|
1199
|
+
const bootstrapPR = bootstrapPRs.get(repoFullName);
|
|
1200
|
+
if (bootstrapPR) {
|
|
1201
|
+
if (bootstrapPR.entireSettingsBundled) {
|
|
1202
|
+
// .entire/settings.json made it into the bootstrap PR's commit.
|
|
1203
|
+
console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in bootstrap PR)'));
|
|
1204
|
+
bundled++;
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
// Step 6 shipped the bootstrap PR with .haystack.json only — the
|
|
1208
|
+
// optional settings read failed transiently. Don't count it as
|
|
1209
|
+
// configured; the warning was already shown in step 6.
|
|
1210
|
+
console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
|
|
1211
|
+
chalk.dim(' — bootstrap PR has .haystack.json only; re-run setup to add session tracking'));
|
|
1212
|
+
}
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
725
1215
|
try {
|
|
726
1216
|
process.stdout.write(chalk.dim(` Configuring ${repoFullName}...`));
|
|
727
1217
|
await configureEntireSettingsViaAPI(repoFullName, token);
|
|
@@ -734,9 +1224,9 @@ async function stepInstallEntire(selectedRepos, token) {
|
|
|
734
1224
|
console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
|
|
735
1225
|
}
|
|
736
1226
|
}
|
|
737
|
-
if (configured > 0) {
|
|
738
|
-
console.log(chalk.green(`\n ✓ Session tracking configured on ${configured} repo(s) (transcripts auto-compacted)\n`));
|
|
739
|
-
trackSetupEvent('entire_configured', { configured, total: selectedRepos.length });
|
|
1227
|
+
if (configured > 0 || bundled > 0) {
|
|
1228
|
+
console.log(chalk.green(`\n ✓ Session tracking configured on ${configured + bundled} repo(s) (transcripts auto-compacted)\n`));
|
|
1229
|
+
trackSetupEvent('entire_configured', { configured: configured + bundled, total: selectedRepos.length });
|
|
740
1230
|
}
|
|
741
1231
|
else {
|
|
742
1232
|
console.log(chalk.yellow('\n ⚠ Could not configure session tracking on any repos.\n'));
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ const program = new Command();
|
|
|
38
38
|
program
|
|
39
39
|
.name('haystack')
|
|
40
40
|
.description('Haystack CLI — automated PR review, triage, and merge queue')
|
|
41
|
-
.version('0.
|
|
41
|
+
.version('0.14.1');
|
|
42
42
|
program
|
|
43
43
|
.command('init')
|
|
44
44
|
.description('Create .haystack.json configuration')
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,9 @@ declare const MergeQueueConfigSchema: z.ZodObject<{
|
|
|
63
63
|
merge_queue: z.ZodOptional<z.ZodBoolean>;
|
|
64
64
|
/** Automatically rebase PRs that develop merge conflicts after pushes to the base branch */
|
|
65
65
|
auto_resolve_conflicts: z.ZodOptional<z.ZodBoolean>;
|
|
66
|
+
/** Dispatch the Haystack agent to fix failing CI checks. Defaults to true; set
|
|
67
|
+
* false to escalate CI failures to the PR author instead of attempting a fix. */
|
|
68
|
+
auto_fix_ci_failures: z.ZodOptional<z.ZodBoolean>;
|
|
66
69
|
/** Grace period configuration before auto-merging */
|
|
67
70
|
merge_debounce: z.ZodOptional<z.ZodObject<{
|
|
68
71
|
/** Default wait time in minutes before auto-merging */
|
|
@@ -113,6 +116,7 @@ declare const MergeQueueConfigSchema: z.ZodObject<{
|
|
|
113
116
|
}, "strip", z.ZodTypeAny, {
|
|
114
117
|
merge_queue?: boolean | undefined;
|
|
115
118
|
auto_resolve_conflicts?: boolean | undefined;
|
|
119
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
116
120
|
merge_debounce?: {
|
|
117
121
|
default_minutes?: number | undefined;
|
|
118
122
|
authors?: Record<string, number> | undefined;
|
|
@@ -130,6 +134,7 @@ declare const MergeQueueConfigSchema: z.ZodObject<{
|
|
|
130
134
|
}, {
|
|
131
135
|
merge_queue?: boolean | undefined;
|
|
132
136
|
auto_resolve_conflicts?: boolean | undefined;
|
|
137
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
133
138
|
merge_debounce?: {
|
|
134
139
|
default_minutes?: number | undefined;
|
|
135
140
|
authors?: Record<string, number> | undefined;
|
|
@@ -1179,6 +1184,9 @@ export declare const HaystackConfigSchema: z.ZodObject<{
|
|
|
1179
1184
|
merge_queue: z.ZodOptional<z.ZodBoolean>;
|
|
1180
1185
|
/** Automatically rebase PRs that develop merge conflicts after pushes to the base branch */
|
|
1181
1186
|
auto_resolve_conflicts: z.ZodOptional<z.ZodBoolean>;
|
|
1187
|
+
/** Dispatch the Haystack agent to fix failing CI checks. Defaults to true; set
|
|
1188
|
+
* false to escalate CI failures to the PR author instead of attempting a fix. */
|
|
1189
|
+
auto_fix_ci_failures: z.ZodOptional<z.ZodBoolean>;
|
|
1182
1190
|
/** Grace period configuration before auto-merging */
|
|
1183
1191
|
merge_debounce: z.ZodOptional<z.ZodObject<{
|
|
1184
1192
|
/** Default wait time in minutes before auto-merging */
|
|
@@ -1229,6 +1237,7 @@ export declare const HaystackConfigSchema: z.ZodObject<{
|
|
|
1229
1237
|
}, "strip", z.ZodTypeAny, {
|
|
1230
1238
|
merge_queue?: boolean | undefined;
|
|
1231
1239
|
auto_resolve_conflicts?: boolean | undefined;
|
|
1240
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
1232
1241
|
merge_debounce?: {
|
|
1233
1242
|
default_minutes?: number | undefined;
|
|
1234
1243
|
authors?: Record<string, number> | undefined;
|
|
@@ -1246,6 +1255,7 @@ export declare const HaystackConfigSchema: z.ZodObject<{
|
|
|
1246
1255
|
}, {
|
|
1247
1256
|
merge_queue?: boolean | undefined;
|
|
1248
1257
|
auto_resolve_conflicts?: boolean | undefined;
|
|
1258
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
1249
1259
|
merge_debounce?: {
|
|
1250
1260
|
default_minutes?: number | undefined;
|
|
1251
1261
|
authors?: Record<string, number> | undefined;
|
|
@@ -1280,6 +1290,7 @@ export declare const HaystackConfigSchema: z.ZodObject<{
|
|
|
1280
1290
|
merge_queue?: {
|
|
1281
1291
|
merge_queue?: boolean | undefined;
|
|
1282
1292
|
auto_resolve_conflicts?: boolean | undefined;
|
|
1293
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
1283
1294
|
merge_debounce?: {
|
|
1284
1295
|
default_minutes?: number | undefined;
|
|
1285
1296
|
authors?: Record<string, number> | undefined;
|
|
@@ -1406,6 +1417,7 @@ export declare const HaystackConfigSchema: z.ZodObject<{
|
|
|
1406
1417
|
merge_queue?: {
|
|
1407
1418
|
merge_queue?: boolean | undefined;
|
|
1408
1419
|
auto_resolve_conflicts?: boolean | undefined;
|
|
1420
|
+
auto_fix_ci_failures?: boolean | undefined;
|
|
1409
1421
|
merge_debounce?: {
|
|
1410
1422
|
default_minutes?: number | undefined;
|
|
1411
1423
|
authors?: Record<string, number> | undefined;
|
package/dist/types.js
CHANGED
|
@@ -123,6 +123,9 @@ const MergeQueueConfigSchema = z.object({
|
|
|
123
123
|
merge_queue: z.boolean().optional(),
|
|
124
124
|
/** Automatically rebase PRs that develop merge conflicts after pushes to the base branch */
|
|
125
125
|
auto_resolve_conflicts: z.boolean().optional(),
|
|
126
|
+
/** Dispatch the Haystack agent to fix failing CI checks. Defaults to true; set
|
|
127
|
+
* false to escalate CI failures to the PR author instead of attempting a fix. */
|
|
128
|
+
auto_fix_ci_failures: z.boolean().optional(),
|
|
126
129
|
/** Grace period configuration before auto-merging */
|
|
127
130
|
merge_debounce: z.object({
|
|
128
131
|
/** Default wait time in minutes before auto-merging */
|