@haystackeditor/cli 0.13.4 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) {
@@ -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: 'chore: configure Haystack via CLI setup wizard',
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 (!response.ok) {
114
- const err = await response.text();
115
- throw new Error(`Failed to write config: ${response.status} — ${err}`);
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
- console.log(chalk.green(` ✓ ${repoFullName}`));
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
- await stepInstallEntire(selectedRepos, token);
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 via the GitHub API.
599
- * selectedRepos are GitHub full names (owner/repo), not local paths.
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 filePath = '.entire/settings.json';
604
- const headers = {
605
- Authorization: `Bearer ${token}`,
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
- const content = Buffer.from(updatedContent).toString('base64');
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/${filePath}`, {
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/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 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {